diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index f69f1fd0..d832f35e 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -79,6 +79,7 @@ jobs: # Spring Boot 관련 FRONTEND_BASE_URL: http://localhost:3000 BACKEND_BASE_URL: https://dev-api.sulmoon.io + AI_SERVER_BASE_URL: http://ai.sulmoon.io:8000 # New Relic 관련 NEW_RELIC_APP_NAME: sulmun2yong-development @@ -99,5 +100,5 @@ jobs: "docker pull ${{ env.DOCKER_ID }}/${{ env.DOCKER_IMAGE_NAME }}", "docker stop ${{ env.CONTAINER_NAME }} || true", "docker rm ${{ env.CONTAINER_NAME }} || true", - "docker run -d --name ${{ env.CONTAINER_NAME }} -p 8080:8080 -e SPRING_DATA_MONGODB_URI=${{ env.MONGODB_URL }} -e SPRING_DATA_MONGODB_DATABASE=${{ env.MONGODB_DATABASE }} -e FRONTEND_BASE-URL=${{ env.FRONTEND_BASE_URL }} -e BACKEND_BASE-URL=${{ env.BACKEND_BASE_URL }} -e NEW_RELIC_APP_NAME=${{ env.NEW_RELIC_APP_NAME }} ${{ env.DOCKER_ID }}/${{ env.DOCKER_IMAGE_NAME }}", + "docker run -d --name ${{ env.CONTAINER_NAME }} -p 8080:8080 -e SPRING_DATA_MONGODB_URI=${{ env.MONGODB_URL }} -e SPRING_DATA_MONGODB_DATABASE=${{ env.MONGODB_DATABASE }} -e FRONTEND_BASE-URL=${{ env.FRONTEND_BASE_URL }} -e BACKEND_BASE-URL=${{ env.BACKEND_BASE_URL }} -e AI-SERVER_BASE-URL=${{ env.AI_SERVER_BASE_URL }} -e NEW_RELIC_APP_NAME=${{ env.NEW_RELIC_APP_NAME }} ${{ env.DOCKER_ID }}/${{ env.DOCKER_IMAGE_NAME }}", "docker image prune -af"]}' diff --git a/build.gradle.kts b/build.gradle.kts index 22f1cdb1..93ec6cce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -50,6 +51,10 @@ dependencies { // mongoDB implementation("org.springframework.boot:spring-boot-starter-data-mongodb") + // mongock + implementation("io.mongock:mongock-springboot:5.4.4") + implementation("io.mongock:mongodb-springdata-v4-driver:5.4.4") + // validation implementation("org.springframework.boot:spring-boot-starter-validation") @@ -57,6 +62,10 @@ dependencies { implementation("org.springdoc:springdoc-openapi:2.3.0") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") + // AWS + implementation("software.amazon.awssdk:bom:2.27.24") + implementation("software.amazon.awssdk:s3:2.27.24") + // test testImplementation("org.mockito:mockito-core:4.0.0") testImplementation("org.mockito:mockito-inline:4.0.0") @@ -75,6 +84,13 @@ kotlin { } } +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + tasks.withType { useJUnitPlatform() } diff --git a/docker-compose.yml b/docker-compose.yml index 775388ec..ec244f9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,20 @@ services: volumes: - sulmun2yong-test-mongodb:/data/db + sulmun2yong-test-redis: + image: redis:latest + container_name: sulmun2yong-test-redis + restart: always + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + expose: + - "6379" + ports: + - "6379:6379" + volumes: + - sulmun2yong-test-redis:/data + volumes: sulmun2yong-test-db: sulmun2yong-test-mongodb: + sulmun2yong-test-redis: diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/adapter/GenerateAdapter.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/adapter/GenerateAdapter.kt new file mode 100644 index 00000000..46f998b8 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/adapter/GenerateAdapter.kt @@ -0,0 +1,78 @@ +package com.sbl.sulmun2yong.ai.adapter + +import com.sbl.sulmun2yong.ai.dto.ChatSessionIdWithSurveyGeneratedByAI +import com.sbl.sulmun2yong.ai.dto.response.AISurveyGenerationResponse +import com.sbl.sulmun2yong.ai.exception.SurveyGenerationByAIFailedException +import com.sbl.sulmun2yong.global.error.PythonServerExceptionMapper +import com.sbl.sulmun2yong.survey.dto.response.SurveyMakeInfoResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.RestTemplate + +@Component +class GenerateAdapter( + @Value("\${ai-server.base-url}") + private val aiServerBaseUrl: String, + private val restTemplate: RestTemplate, +) { + fun requestSurveyGenerationWithFileUrl( + job: String, + groupName: String, + fileUrl: String, + userPrompt: String, + ): AISurveyGenerationResponse { + val requestUrl = "$aiServerBaseUrl/generate/survey/file-url" + + val requestBody = + mapOf( + "job" to job, + "group_name" to groupName, + "file_url" to fileUrl, + "user_prompt" to userPrompt, + ) + + return requestToGenerateSurvey(requestUrl, requestBody) + } + + fun requestSurveyGenerationWithTextDocument( + job: String, + groupName: String, + textDocument: String, + userPrompt: String, + ): AISurveyGenerationResponse { + val requestUrl = "$aiServerBaseUrl/generate/survey/text-document" + + val requestBody = + mapOf( + "job" to job, + "group_name" to groupName, + "text_document" to textDocument, + "user_prompt" to userPrompt, + ) + + return requestToGenerateSurvey(requestUrl, requestBody) + } + + private fun requestToGenerateSurvey( + requestUrl: String, + requestBody: Map, + ): AISurveyGenerationResponse { + val chatSessionIdWithSurveyGeneratedByAI = + try { + restTemplate + .postForEntity( + requestUrl, + requestBody, + ChatSessionIdWithSurveyGeneratedByAI::class.java, + ).body ?: throw SurveyGenerationByAIFailedException() + } catch (e: HttpClientErrorException) { + throw PythonServerExceptionMapper.mapException(e) + } + + val chatSessionId = chatSessionIdWithSurveyGeneratedByAI.chatSessionId + val survey = chatSessionIdWithSurveyGeneratedByAI.surveyGeneratedByAI.toDomain() + val surveyMakeInfoResponse = SurveyMakeInfoResponse.of(survey) + return AISurveyGenerationResponse.from(chatSessionId, surveyMakeInfoResponse) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/controller/AIGenerateController.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/controller/AIGenerateController.kt new file mode 100644 index 00000000..902779dc --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/controller/AIGenerateController.kt @@ -0,0 +1,55 @@ +package com.sbl.sulmun2yong.ai.controller + +import com.sbl.sulmun2yong.ai.controller.doc.AIGenerateApiDoc +import com.sbl.sulmun2yong.ai.dto.request.SurveyGenerationWithFileUrlRequest +import com.sbl.sulmun2yong.ai.dto.request.SurveyGenerationWithTextDocumentRequest +import com.sbl.sulmun2yong.ai.service.GenerateService +import com.sbl.sulmun2yong.survey.dto.response.SurveyMakeInfoResponse +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/ai/generate") +class AIGenerateController( + private val generateService: GenerateService, +) : AIGenerateApiDoc { + @PostMapping("/survey/file-url") + override fun generateSurveyWithFileUrl( + @RequestBody surveyGenerationWithFileUrlRequest: SurveyGenerationWithFileUrlRequest, + response: HttpServletResponse, + ): ResponseEntity { + val aiSurveyGenerationResponse = generateService.generateSurveyWithFileUrl(surveyGenerationWithFileUrlRequest) + setChatSessionIdCookie(response, aiSurveyGenerationResponse.chatSessionId) + return ResponseEntity.ok(aiSurveyGenerationResponse.generatedSurvey) + } + + @PostMapping("/survey/text-document") + override fun generateSurveyWithTextDocument( + @RequestBody surveyGenerationWithTextDocumentRequest: SurveyGenerationWithTextDocumentRequest, + response: HttpServletResponse, + ): ResponseEntity { + val aiSurveyGenerationResponse = generateService.generateSurveyWithTextDocument(surveyGenerationWithTextDocumentRequest) + setChatSessionIdCookie(response, aiSurveyGenerationResponse.chatSessionId) + return ResponseEntity.ok(aiSurveyGenerationResponse.generatedSurvey) + } + + private fun setChatSessionIdCookie( + response: HttpServletResponse, + chatSessionId: UUID, + ) { + val cookie = + Cookie("chat-session-id", chatSessionId.toString()).apply { + maxAge = 3600 + path = "/" + isHttpOnly = true + } + + response.addCookie(cookie) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/controller/doc/AIGenerateApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/controller/doc/AIGenerateApiDoc.kt new file mode 100644 index 00000000..1ebaa506 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/controller/doc/AIGenerateApiDoc.kt @@ -0,0 +1,28 @@ +package com.sbl.sulmun2yong.ai.controller.doc + +import com.sbl.sulmun2yong.ai.dto.request.SurveyGenerationWithFileUrlRequest +import com.sbl.sulmun2yong.ai.dto.request.SurveyGenerationWithTextDocumentRequest +import com.sbl.sulmun2yong.survey.dto.response.SurveyMakeInfoResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody + +@Tag(name = "AI", description = "AI 기능 관련 API") +interface AIGenerateApiDoc { + @Operation(summary = "파일을 통한 AI 설문 생성") + @PostMapping("/survey/file-url") + fun generateSurveyWithFileUrl( + @RequestBody surveyGenerationWithFileUrlRequest: SurveyGenerationWithFileUrlRequest, + response: HttpServletResponse, + ): ResponseEntity + + @Operation(summary = "텍스트 입력을 통한 AI 설문 생성") + @PostMapping("/survey/text-document") + fun generateSurveyWithTextDocument( + @RequestBody surveyGenerationWithTextDocumentRequest: SurveyGenerationWithTextDocumentRequest, + response: HttpServletResponse, + ): ResponseEntity +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/ChatSessionIdWithSurveyGeneratedByAI.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/ChatSessionIdWithSurveyGeneratedByAI.kt new file mode 100644 index 00000000..04fcb940 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/ChatSessionIdWithSurveyGeneratedByAI.kt @@ -0,0 +1,8 @@ +package com.sbl.sulmun2yong.ai.dto + +import java.util.UUID + +class ChatSessionIdWithSurveyGeneratedByAI( + val chatSessionId: UUID, + val surveyGeneratedByAI: SurveyGeneratedByAI, +) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/QuestionGeneratedByAI.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/QuestionGeneratedByAI.kt new file mode 100644 index 00000000..287feb81 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/QuestionGeneratedByAI.kt @@ -0,0 +1,50 @@ +package com.sbl.sulmun2yong.ai.dto + +import com.sbl.sulmun2yong.survey.domain.question.QuestionType +import com.sbl.sulmun2yong.survey.domain.question.choice.Choice +import com.sbl.sulmun2yong.survey.domain.question.choice.Choices +import com.sbl.sulmun2yong.survey.domain.question.impl.StandardMultipleChoiceQuestion +import com.sbl.sulmun2yong.survey.domain.question.impl.StandardSingleChoiceQuestion +import com.sbl.sulmun2yong.survey.domain.question.impl.StandardTextQuestion +import java.util.UUID + +class QuestionGeneratedByAI( + private val questionType: QuestionType, + private val title: String, + private val isRequired: Boolean, + private val choices: List?, + private val isAllowOther: Boolean, +) { + fun toDomain() = + when (questionType) { + QuestionType.SINGLE_CHOICE -> + StandardSingleChoiceQuestion( + id = UUID.randomUUID(), + title = this.title, + description = DEFAULT_DESCRIPTION, + isRequired = this.isRequired, + // TODO: Document를 Domain클래스로 변환 중에 생긴 에러는 여기서 직접 반환하도록 수정 + choices = Choices(this.choices?.map { Choice.Standard(it) } ?: listOf(), isAllowOther), + ) + QuestionType.MULTIPLE_CHOICE -> + StandardMultipleChoiceQuestion( + id = UUID.randomUUID(), + title = this.title, + description = DEFAULT_DESCRIPTION, + isRequired = this.isRequired, + // TODO: Document를 Domain클래스로 변환 중에 생긴 에러는 여기서 직접 반환하도록 수정 + choices = Choices(this.choices?.map { Choice.Standard(it) } ?: listOf(), isAllowOther), + ) + QuestionType.TEXT_RESPONSE -> + StandardTextQuestion( + id = UUID.randomUUID(), + title = this.title, + description = DEFAULT_DESCRIPTION, + isRequired = this.isRequired, + ) + } + + companion object { + private const val DEFAULT_DESCRIPTION = "" + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/SectionGeneratedByAI.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/SectionGeneratedByAI.kt new file mode 100644 index 00000000..82cb86a7 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/SectionGeneratedByAI.kt @@ -0,0 +1,27 @@ +package com.sbl.sulmun2yong.ai.dto + +import com.sbl.sulmun2yong.survey.domain.routing.RoutingStrategy +import com.sbl.sulmun2yong.survey.domain.section.Section +import com.sbl.sulmun2yong.survey.domain.section.SectionId +import com.sbl.sulmun2yong.survey.domain.section.SectionIds + +class SectionGeneratedByAI( + val title: String, + val description: String, + val questions: List, +) { + private val defaultRoutingStrategy = RoutingStrategy.NumericalOrder + + fun toDomain( + sectionId: SectionId.Standard, + sectionIds: SectionIds, + ): Section = + Section( + id = sectionId, + title = title, + description = description, + routingStrategy = defaultRoutingStrategy, + questions = questions.map { it.toDomain() }, + sectionIds = sectionIds, + ) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/SurveyGeneratedByAI.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/SurveyGeneratedByAI.kt new file mode 100644 index 00000000..861a06cc --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/SurveyGeneratedByAI.kt @@ -0,0 +1,37 @@ +package com.sbl.sulmun2yong.ai.dto + +import com.sbl.sulmun2yong.survey.domain.Survey +import com.sbl.sulmun2yong.survey.domain.section.SectionId +import com.sbl.sulmun2yong.survey.domain.section.SectionIds +import java.util.UUID + +class SurveyGeneratedByAI( + private val title: String, + private val description: String, + private val finishMessage: String, + private val sections: List, +) { + fun toDomain(): Survey { + val sectionIds = List(sections.size) { SectionId.Standard(UUID.randomUUID()) } + val sectionIdsManger = SectionIds.from(sectionIds) + + val sections = + sections.mapIndexed { index, sectionGeneratedByAI -> + sectionGeneratedByAI.toDomain( + sectionIds[index], + sectionIdsManger, + ) + } + + val survey = Survey.create(UUID.randomUUID()) + return survey.updateContent( + title = title, + description = description, + thumbnail = survey.thumbnail, + finishMessage = finishMessage, + rewardSetting = survey.rewardSetting, + isVisible = false, + sections = sections, + ) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/request/SurveyGenerationWithFileUrlRequest.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/request/SurveyGenerationWithFileUrlRequest.kt new file mode 100644 index 00000000..e3b0422b --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/request/SurveyGenerationWithFileUrlRequest.kt @@ -0,0 +1,8 @@ +package com.sbl.sulmun2yong.ai.dto.request + +data class SurveyGenerationWithFileUrlRequest( + val job: String, + val groupName: String, + val fileUrl: String, + val userPrompt: String, +) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/request/SurveyGenerationWithTextDocumentRequest.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/request/SurveyGenerationWithTextDocumentRequest.kt new file mode 100644 index 00000000..57bb2ef4 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/request/SurveyGenerationWithTextDocumentRequest.kt @@ -0,0 +1,8 @@ +package com.sbl.sulmun2yong.ai.dto.request + +data class SurveyGenerationWithTextDocumentRequest( + val job: String, + val groupName: String, + val textDocument: String, + val userPrompt: String, +) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/response/AISurveyGenerationResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/response/AISurveyGenerationResponse.kt new file mode 100644 index 00000000..dfe952c3 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/dto/response/AISurveyGenerationResponse.kt @@ -0,0 +1,16 @@ +package com.sbl.sulmun2yong.ai.dto.response + +import com.sbl.sulmun2yong.survey.dto.response.SurveyMakeInfoResponse +import java.util.UUID + +class AISurveyGenerationResponse( + val chatSessionId: UUID, + val generatedSurvey: SurveyMakeInfoResponse, +) { + companion object { + fun from( + chatSessionId: UUID, + generatedSurvey: SurveyMakeInfoResponse, + ): AISurveyGenerationResponse = AISurveyGenerationResponse(chatSessionId, generatedSurvey) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/FileExtensionNotSupportedException.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/FileExtensionNotSupportedException.kt new file mode 100644 index 00000000..4cdb491c --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/FileExtensionNotSupportedException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.ai.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class FileExtensionNotSupportedException : BusinessException(ErrorCode.FILE_EXTENSION_NOT_SUPPORTED) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/FileNotFoundException.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/FileNotFoundException.kt new file mode 100644 index 00000000..f531f599 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/FileNotFoundException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.ai.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class FileNotFoundException : BusinessException(ErrorCode.FILE_NOT_FOUND) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/SurveyGenerationByAIFailedException.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/SurveyGenerationByAIFailedException.kt new file mode 100644 index 00000000..4c0f3731 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/SurveyGenerationByAIFailedException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.ai.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class SurveyGenerationByAIFailedException : BusinessException(ErrorCode.SURVEY_GENERATION_BY_AI_FAILED) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/TextTooLongException.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/TextTooLongException.kt new file mode 100644 index 00000000..fcf2652a --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/exception/TextTooLongException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.ai.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class TextTooLongException : BusinessException(ErrorCode.TEXT_TOO_LONG) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/ai/service/GenerateService.kt b/src/main/kotlin/com/sbl/sulmun2yong/ai/service/GenerateService.kt new file mode 100644 index 00000000..947f2a05 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/ai/service/GenerateService.kt @@ -0,0 +1,38 @@ +package com.sbl.sulmun2yong.ai.service + +import com.sbl.sulmun2yong.ai.adapter.GenerateAdapter +import com.sbl.sulmun2yong.ai.dto.request.SurveyGenerationWithFileUrlRequest +import com.sbl.sulmun2yong.ai.dto.request.SurveyGenerationWithTextDocumentRequest +import com.sbl.sulmun2yong.ai.dto.response.AISurveyGenerationResponse +import com.sbl.sulmun2yong.global.util.validator.FileUrlValidator +import org.springframework.stereotype.Service + +@Service +class GenerateService( + private val fileUrlValidator: FileUrlValidator, + private val generateAdapter: GenerateAdapter, +) { + fun generateSurveyWithFileUrl(surveyGenerationWithFileUrlRequest: SurveyGenerationWithFileUrlRequest): AISurveyGenerationResponse { + val allowedExtensions = mutableListOf(".txt", ".pdf") + + val job = surveyGenerationWithFileUrlRequest.job + val groupName = surveyGenerationWithFileUrlRequest.groupName + val fileUrl = surveyGenerationWithFileUrlRequest.fileUrl + val userPrompt = surveyGenerationWithFileUrlRequest.userPrompt + + fileUrlValidator.validateFileUrlOf(fileUrl, allowedExtensions) + + return generateAdapter.requestSurveyGenerationWithFileUrl(job, groupName, fileUrl, userPrompt) + } + + fun generateSurveyWithTextDocument( + surveyGenerationWithTextDocumentRequest: SurveyGenerationWithTextDocumentRequest, + ): AISurveyGenerationResponse { + val job = surveyGenerationWithTextDocumentRequest.job + val groupName = surveyGenerationWithTextDocumentRequest.groupName + val textDocument = surveyGenerationWithTextDocumentRequest.textDocument + val userPrompt = surveyGenerationWithTextDocumentRequest.userPrompt + + return generateAdapter.requestSurveyGenerationWithTextDocument(job, groupName, textDocument, userPrompt) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/aws/controller/S3Controller.kt b/src/main/kotlin/com/sbl/sulmun2yong/aws/controller/S3Controller.kt new file mode 100644 index 00000000..a42e22e7 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/aws/controller/S3Controller.kt @@ -0,0 +1,22 @@ +package com.sbl.sulmun2yong.aws.controller + +import com.sbl.sulmun2yong.aws.controller.doc.S3ApiDoc +import com.sbl.sulmun2yong.aws.dto.request.S3UploadRequest +import com.sbl.sulmun2yong.aws.dto.response.S3UploadResponse +import com.sbl.sulmun2yong.aws.service.S3Service +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/s3") +class S3Controller( + private val s3Service: S3Service, +) : S3ApiDoc { + @PostMapping("/upload") + override fun upload( + @ModelAttribute request: S3UploadRequest, + ): ResponseEntity = ResponseEntity.ok(s3Service.uploadFile(request.file)) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/aws/controller/doc/S3ApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/aws/controller/doc/S3ApiDoc.kt new file mode 100644 index 00000000..2715ed3b --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/aws/controller/doc/S3ApiDoc.kt @@ -0,0 +1,18 @@ +package com.sbl.sulmun2yong.aws.controller.doc + +import com.sbl.sulmun2yong.aws.dto.request.S3UploadRequest +import com.sbl.sulmun2yong.aws.dto.response.S3UploadResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody + +@Tag(name = "AWS", description = "AWS 관련 API") +interface S3ApiDoc { + @Operation(summary = "S3 업로드") + @PostMapping("/upload") + fun upload( + @RequestBody request: S3UploadRequest, + ): ResponseEntity +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/aws/dto/request/S3UploadRequest.kt b/src/main/kotlin/com/sbl/sulmun2yong/aws/dto/request/S3UploadRequest.kt new file mode 100644 index 00000000..f14c552b --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/aws/dto/request/S3UploadRequest.kt @@ -0,0 +1,7 @@ +package com.sbl.sulmun2yong.aws.dto.request + +import org.springframework.web.multipart.MultipartFile + +class S3UploadRequest( + val file: MultipartFile, +) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/aws/dto/response/S3UploadResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/aws/dto/response/S3UploadResponse.kt new file mode 100644 index 00000000..1b1876b6 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/aws/dto/response/S3UploadResponse.kt @@ -0,0 +1,5 @@ +package com.sbl.sulmun2yong.aws.dto.response + +data class S3UploadResponse( + val fileUrl: String, +) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/aws/service/S3Service.kt b/src/main/kotlin/com/sbl/sulmun2yong/aws/service/S3Service.kt new file mode 100644 index 00000000..16d3a9c0 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/aws/service/S3Service.kt @@ -0,0 +1,40 @@ +package com.sbl.sulmun2yong.aws.service + +import com.sbl.sulmun2yong.aws.dto.response.S3UploadResponse +import com.sbl.sulmun2yong.global.util.validator.FileUploadValidator +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Service +class S3Service( + private val s3Client: S3Client, + @Value("\${aws.s3.bucket-name}") + private val bucketName: String, + @Value("\${cloudfront.base-url}") + private val cloudFrontUrl: String, + private val fileUploadValidator: FileUploadValidator, +) { + fun uploadFile(receivedFile: MultipartFile): S3UploadResponse { + fileUploadValidator.validateFileOf(receivedFile) + + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmssSSS")) + val keyName = "${timestamp}_${receivedFile.originalFilename}" + + val putObjectRequest = + PutObjectRequest + .builder() + .bucket(bucketName) + .key(keyName) + .build() + + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(receivedFile.inputStream, receivedFile.size)) + + return S3UploadResponse("$cloudFrontUrl/$keyName") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingBoardAdapter.kt b/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingBoardAdapter.kt index d299a815..a96e1fdc 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingBoardAdapter.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingBoardAdapter.kt @@ -18,6 +18,10 @@ class DrawingBoardAdapter( .toDomain() fun save(drawingBoard: DrawingBoard) { - drawingBoardRepository.save(DrawingBoardDocument.of(drawingBoard)) + val previousDrawingBoardDocument = drawingBoardRepository.findById(drawingBoard.id) + val drawingBoardDocument = DrawingBoardDocument.of(drawingBoard) + // 기존 추첨 보드를 업데이트하는 경우, createdAt을 유지 + if (previousDrawingBoardDocument.isPresent) drawingBoardDocument.createdAt = previousDrawingBoardDocument.get().createdAt + drawingBoardRepository.save(drawingBoardDocument) } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingHistoryAdapter.kt b/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingHistoryAdapter.kt index b0ac08a8..ac1e5f19 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingHistoryAdapter.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/drawing/adapter/DrawingHistoryAdapter.kt @@ -3,7 +3,6 @@ package com.sbl.sulmun2yong.drawing.adapter import com.sbl.sulmun2yong.drawing.domain.DrawingHistory import com.sbl.sulmun2yong.drawing.domain.DrawingHistoryGroup import com.sbl.sulmun2yong.drawing.entity.DrawingHistoryDocument -import com.sbl.sulmun2yong.drawing.exception.InvalidDrawingHistoryException import com.sbl.sulmun2yong.drawing.repository.DrawingHistoryRepository import com.sbl.sulmun2yong.global.data.PhoneNumber import org.springframework.stereotype.Component @@ -13,8 +12,8 @@ import java.util.UUID class DrawingHistoryAdapter( private val drawingHistoryRepository: DrawingHistoryRepository, ) { - fun save(drawingHistory: DrawingHistory) { - drawingHistoryRepository.save(DrawingHistoryDocument.of(drawingHistory)) + fun insert(drawingHistory: DrawingHistory) { + drawingHistoryRepository.insert(DrawingHistoryDocument.of(drawingHistory)) } fun findBySurveyIdAndParticipantIdOrPhoneNumber( @@ -37,7 +36,11 @@ class DrawingHistoryAdapter( false -> drawingHistoryRepository.findBySurveyId(surveyId) } if (!dto.isPresent) { - throw InvalidDrawingHistoryException() + return DrawingHistoryGroup( + surveyId = surveyId, + count = 0, + histories = emptyList(), + ) } return DrawingHistoryGroup( surveyId = dto.get().id, diff --git a/src/main/kotlin/com/sbl/sulmun2yong/drawing/domain/DrawingBoard.kt b/src/main/kotlin/com/sbl/sulmun2yong/drawing/domain/DrawingBoard.kt index ec15951a..9129689c 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/drawing/domain/DrawingBoard.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/drawing/domain/DrawingBoard.kt @@ -4,7 +4,7 @@ import com.sbl.sulmun2yong.drawing.domain.drawingResult.DrawingResult import com.sbl.sulmun2yong.drawing.domain.ticket.Ticket import com.sbl.sulmun2yong.drawing.exception.AlreadySelectedTicketException import com.sbl.sulmun2yong.drawing.exception.InvalidDrawingBoardException -import com.sbl.sulmun2yong.survey.domain.Reward +import com.sbl.sulmun2yong.survey.domain.reward.Reward import java.util.UUID class DrawingBoard( diff --git a/src/main/kotlin/com/sbl/sulmun2yong/drawing/entity/DrawingHistoryDocument.kt b/src/main/kotlin/com/sbl/sulmun2yong/drawing/entity/DrawingHistoryDocument.kt index da2cf790..6d50528c 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/drawing/entity/DrawingHistoryDocument.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/drawing/entity/DrawingHistoryDocument.kt @@ -3,6 +3,7 @@ package com.sbl.sulmun2yong.drawing.entity import com.sbl.sulmun2yong.drawing.domain.DrawingHistory import com.sbl.sulmun2yong.drawing.domain.ticket.Ticket import com.sbl.sulmun2yong.global.data.PhoneNumber +import com.sbl.sulmun2yong.global.entity.BaseTimeDocument import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document import java.util.UUID @@ -16,7 +17,7 @@ data class DrawingHistoryDocument( val surveyId: UUID, val selectedTicketIndex: Int, val ticket: Ticket, -) { +) : BaseTimeDocument() { companion object { fun of(drawingHistory: DrawingHistory) = DrawingHistoryDocument( diff --git a/src/main/kotlin/com/sbl/sulmun2yong/drawing/service/DrawingBoardService.kt b/src/main/kotlin/com/sbl/sulmun2yong/drawing/service/DrawingBoardService.kt index 5ede7432..87f9067b 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/drawing/service/DrawingBoardService.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/drawing/service/DrawingBoardService.kt @@ -2,7 +2,6 @@ package com.sbl.sulmun2yong.drawing.service import com.sbl.sulmun2yong.drawing.adapter.DrawingBoardAdapter import com.sbl.sulmun2yong.drawing.adapter.DrawingHistoryAdapter -import com.sbl.sulmun2yong.drawing.domain.DrawingBoard import com.sbl.sulmun2yong.drawing.domain.DrawingHistory import com.sbl.sulmun2yong.drawing.domain.drawingResult.DrawingResult import com.sbl.sulmun2yong.drawing.dto.response.DrawingBoardResponse @@ -13,7 +12,6 @@ import com.sbl.sulmun2yong.drawing.exception.InvalidDrawingBoardAccessException import com.sbl.sulmun2yong.global.data.PhoneNumber import com.sbl.sulmun2yong.survey.adapter.ParticipantAdapter import com.sbl.sulmun2yong.survey.adapter.SurveyAdapter -import com.sbl.sulmun2yong.survey.domain.Reward import com.sbl.sulmun2yong.survey.domain.SurveyStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -46,7 +44,7 @@ class DrawingBoardService( ): DrawingResultResponse { // 유효성 검증 // 참가했는가 - val participant = participantAdapter.getParticipant(participantId) + val participant = participantAdapter.getByParticipantId(participantId) val surveyId = participant.surveyId // 추첨 기록이 있는가 @@ -72,7 +70,7 @@ class DrawingBoardService( val changedDrawingBoard = drawingResult.changedDrawingBoard drawingBoardAdapter.save(drawingResult.changedDrawingBoard) // 추첨 기록 저장 - drawingHistoryAdapter.save( + drawingHistoryAdapter.insert( DrawingHistory.create( participantId = participantId, phoneNumber = phoneNumberData, @@ -94,29 +92,4 @@ class DrawingBoardService( } return drawingResultResponse } - - fun makeDrawingBoard( - surveyId: UUID, - boardSize: Int, - surveyRewards: List, - ) { - // TODO: 적절한 다른 패키지간 도메인 변환 로직 도입 - val rewards = - surveyRewards - .map { - Reward( - name = it.name, - category = it.category, - count = it.count, - ) - } - - val drawingBoard = - DrawingBoard.create( - surveyId = surveyId, - boardSize = boardSize, - rewards = rewards, - ) - drawingBoardAdapter.save(drawingBoard) - } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/MongockConfig.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/MongockConfig.kt new file mode 100644 index 00000000..84094222 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/MongockConfig.kt @@ -0,0 +1,8 @@ +package com.sbl.sulmun2yong.global.config + +import io.mongock.runner.springboot.EnableMongock +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableMongock +class MongockConfig diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/RestTemplateConfig.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/RestTemplateConfig.kt new file mode 100644 index 00000000..bc0a5325 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/RestTemplateConfig.kt @@ -0,0 +1,12 @@ +package com.sbl.sulmun2yong.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestTemplate + +// TODO : WebClient 시용 고려 +@Configuration +class RestTemplateConfig { + @Bean + fun createRestTemplate(): RestTemplate = RestTemplate() +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/SchedulingConfig.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/SchedulingConfig.kt new file mode 100644 index 00000000..a36357a6 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/SchedulingConfig.kt @@ -0,0 +1,8 @@ +package com.sbl.sulmun2yong.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +@Configuration +@EnableScheduling +class SchedulingConfig diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/SecurityConfig.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/SecurityConfig.kt index b57f161d..596c1fde 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/SecurityConfig.kt @@ -1,18 +1,19 @@ package com.sbl.sulmun2yong.global.config import com.sbl.sulmun2yong.global.config.oauth2.CustomOAuth2Service +import com.sbl.sulmun2yong.global.config.oauth2.HttpCookieOAuth2AuthorizationRequestRepository import com.sbl.sulmun2yong.global.config.oauth2.handler.CustomAuthenticationSuccessHandler import com.sbl.sulmun2yong.global.config.oauth2.handler.CustomLogoutSuccessHandler import com.sbl.sulmun2yong.global.config.oauth2.strategy.CustomExpiredSessionStrategy import com.sbl.sulmun2yong.global.config.oauth2.strategy.CustomInvalidSessionStrategy import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.security.servlet.RequestMatcherProvider import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order -import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.invoke import org.springframework.security.core.session.SessionRegistry @@ -30,7 +31,9 @@ import org.springframework.web.filter.ForwardedHeaderFilter @Configuration class SecurityConfig( @Value("\${frontend.base-url}") - private val frontEndBaseUrl: String, + private val frontendBaseUrl: String, + @Value("\${backend.base-url}") + private val backendBaseUrl: String, @Value("\${swagger.username}") private val username: String?, @Value("\${swagger.password}") @@ -44,6 +47,10 @@ class SecurityConfig( @Bean fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + @Bean + fun cookieAuthorizationRequestRepository(): HttpCookieOAuth2AuthorizationRequestRepository = + HttpCookieOAuth2AuthorizationRequestRepository() + @Bean fun userDetailsService(): UserDetailsService { val user = @@ -56,23 +63,33 @@ class SecurityConfig( return InMemoryUserDetailsManager(user) } + @Bean + fun forwardedHeaderFilter(): FilterRegistrationBean { + val filterRegistrationBean = FilterRegistrationBean() + + filterRegistrationBean.filter = ForwardedHeaderFilter() + filterRegistrationBean.order = Ordered.HIGHEST_PRECEDENCE + + return filterRegistrationBean + } + @ConditionalOnProperty(prefix = "swagger", name = ["login"], havingValue = "true") @Order(0) @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { - http - .securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/login") - .csrf { it.disable() } - .authorizeHttpRequests { requests -> - requests - .requestMatchers("/swagger-ui/**") - .hasAnyRole("SWAGGER_USER", "ADMIN") - .requestMatchers("/v3/api-docs/**") - .hasAnyRole("SWAGGER_USER", "ADMIN") - .requestMatchers("/**") - .permitAll() - }.formLogin(Customizer.withDefaults()) - + fun filterChain( + http: HttpSecurity, + requestMatcherProvider: RequestMatcherProvider, + ): SecurityFilterChain { + http { + csrf { disable() } + securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/login") + authorizeHttpRequests { + authorize("/swagger-ui/**", hasAnyRole("SWAGGER_USER", "ADMIN")) + authorize("/v3/api-docs/**", hasAnyRole("SWAGGER_USER", "ADMIN")) + authorize("/**", permitAll) + } + formLogin {} + } return http.build() } @@ -87,21 +104,29 @@ class SecurityConfig( disable() } oauth2Login { + authorizationEndpoint { + baseUri = "/oauth2/authorization" + authorizationRequestRepository = cookieAuthorizationRequestRepository() + } userInfoEndpoint { userService = customOAuth2Service } - authenticationSuccessHandler = CustomAuthenticationSuccessHandler(frontEndBaseUrl) + authenticationSuccessHandler = + CustomAuthenticationSuccessHandler(frontendBaseUrl, backendBaseUrl, cookieAuthorizationRequestRepository()) } logout { logoutUrl = "/user/logout" invalidateHttpSession = false - logoutSuccessHandler = CustomLogoutSuccessHandler(frontEndBaseUrl, sessionRegistry()) + logoutSuccessHandler = CustomLogoutSuccessHandler(frontendBaseUrl, sessionRegistry()) } authorizeHttpRequests { authorize("/api/v1/admin/**", hasRole("ADMIN")) authorize("/api/v1/user/**", authenticated) - // TODO: 추후에 AUTHENTICATED_USER 로 수정 - authorize("/api/v1/surveys/workbench/**", hasRole("ADMIN")) + authorize("/api/v1/surveys/my-page", authenticated) + authorize("/api/v1/surveys/results/**", authenticated) + authorize("/api/v1/s3/**", authenticated) + authorize("/api/v1/ai/**", authenticated) + authorize("/api/v1/surveys/workbench/**", authenticated) authorize("/**", permitAll) } exceptionHandling { @@ -121,14 +146,4 @@ class SecurityConfig( } return http.build() } - - @Bean - fun forwardedHeaderFilter(): FilterRegistrationBean { - val filterRegistrationBean = FilterRegistrationBean() - - filterRegistrationBean.filter = ForwardedHeaderFilter() - filterRegistrationBean.order = Ordered.HIGHEST_PRECEDENCE - - return filterRegistrationBean - } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/UserFileManagementConfig.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/UserFileManagementConfig.kt new file mode 100644 index 00000000..e8a36b82 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/UserFileManagementConfig.kt @@ -0,0 +1,51 @@ +package com.sbl.sulmun2yong.global.config + +import com.sbl.sulmun2yong.global.util.validator.FileUploadValidator +import com.sbl.sulmun2yong.global.util.validator.FileUrlValidator +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client + +@Configuration +class UserFileManagementConfig( + // S3 클라이언트 관련 + @Value("\${aws.s3.access-key}") + private val accessKey: String, + @Value("\${aws.s3.secret-key}") + private val secretKey: String, + // 파일 예외처리 관련 + @Value("\${aws.s3.max-file-name-length}") + private val maxFileNameLength: Int, + @Value("\${aws.s3.allowed-extensions}") + private val allowedExtensions: String, + @Value("\${aws.s3.allowed-content-types}") + private val allowedContentTypes: String, + @Value("\${cloudfront.base-url}") + private val cloudFrontBaseUrl: String, +) { + @Bean + fun createS3Client(): S3Client { + val awsCredentials = AwsBasicCredentials.create(accessKey, secretKey) + + return S3Client + .builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build() + } + + @Bean + fun createFileUploadValidator(): FileUploadValidator = + FileUploadValidator.from( + maxFileNameLength = maxFileNameLength, + allowedExtensions = allowedExtensions, + allowedContentTypes = allowedContentTypes, + ) + + @Bean + fun createFileUrlValidator(): FileUrlValidator = FileUrlValidator.of(cloudFrontBaseUrl) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/WebMvcConfig.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/WebMvcConfig.kt index b4c584cd..f9f8f6ab 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/config/WebMvcConfig.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/WebMvcConfig.kt @@ -11,15 +11,15 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Configuration class WebMvcConfig( @Value("\${frontend.base-url}") - private val baseUrl: String, + private val frontendBaseUrl: String, private val loginUserArgumentResolver: LoginUserArgumentResolver, private val isAdminArgumentResolver: IsAdminArgumentResolver, ) : WebMvcConfigurer { override fun addCorsMappings(registry: CorsRegistry) { registry .addMapping("/**") - .allowedOrigins(baseUrl) // 프론트엔드 도메인 - .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowedOrigins(frontendBaseUrl) // 프론트엔드 도메인 + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") .allowCredentials(true) .allowedHeaders("*") } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2Service.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2Service.kt index 99b692f4..de31bea2 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2Service.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2Service.kt @@ -31,11 +31,12 @@ class CustomOAuth2Service( val oAuth2UserInfo = getOAuth2UserInfo(oAuth2UserRequest, oAuth2User) val provider = oAuth2UserInfo.getProvider() val providerId = oAuth2UserInfo.getProviderId() - val phoneNumber: String? = oAuth2UserInfo.getPhoneNumber() - // TODO: 전화번호가 등록되어 있는 유저가 소셜로그인에서 전화번호를 제공하지 않는 Google이나 Kakao로 로그인 시 - // 전화번호가 사라지고 ROLE이 변경되는 문제 수정하기 val user: User? = userAdapter.findByProviderAndProviderId(provider, providerId) + val currentPhoneNumber = user?.phoneNumber?.value + + val phoneNumber: String? = currentPhoneNumber ?: oAuth2UserInfo.getPhoneNumber() + val upsertedUser = user?.withUpdatePhoneNumber(phoneNumber) ?: User.create( diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2User.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2User.kt index 4913448b..a80289ef 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2User.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/CustomOAuth2User.kt @@ -27,5 +27,6 @@ data class CustomOAuth2User( DefaultUserProfile( id = id, nickname = nickname, + role = role, ) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.kt new file mode 100644 index 00000000..395a181b --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.kt @@ -0,0 +1,59 @@ +package com.sbl.sulmun2yong.global.config.oauth2 + +import com.nimbusds.oauth2.sdk.util.StringUtils +import com.sbl.sulmun2yong.global.util.CookieUtils +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest + +class HttpCookieOAuth2AuthorizationRequestRepository : AuthorizationRequestRepository { + override fun loadAuthorizationRequest(request: HttpServletRequest): OAuth2AuthorizationRequest? { + val cookie = CookieUtils.findCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + return cookie?.let { + CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest::class.java) + } + } + + override fun removeAuthorizationRequest( + request: HttpServletRequest, + response: HttpServletResponse, + ): OAuth2AuthorizationRequest? = this.loadAuthorizationRequest(request) + + override fun saveAuthorizationRequest( + authorizationRequest: OAuth2AuthorizationRequest?, + request: HttpServletRequest, + response: HttpServletResponse, + ) { + if (authorizationRequest == null) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME) + return + } + + CookieUtils.addCookie( + response, + OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, + CookieUtils.serialize(authorizationRequest), + COOKIE_EXPIRE_SECONDS, + ) + val redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME) + if (StringUtils.isNotBlank(redirectUriAfterLogin)) { + CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS) + } + } + + fun removeAuthorizationRequestCookies( + request: HttpServletRequest, + response: HttpServletResponse, + ) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME) + } + + companion object { + const val OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME: String = "oauth2_auth_request" + const val REDIRECT_URI_PARAM_COOKIE_NAME: String = "redirect_uri" + private const val COOKIE_EXPIRE_SECONDS = 180 + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/handler/CustomAuthenticationSuccessHandler.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/handler/CustomAuthenticationSuccessHandler.kt index dd297af9..c9f6fc04 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/handler/CustomAuthenticationSuccessHandler.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/config/oauth2/handler/CustomAuthenticationSuccessHandler.kt @@ -1,24 +1,26 @@ package com.sbl.sulmun2yong.global.config.oauth2.handler import com.sbl.sulmun2yong.global.config.oauth2.CustomOAuth2User +import com.sbl.sulmun2yong.global.config.oauth2.HttpCookieOAuth2AuthorizationRequestRepository +import com.sbl.sulmun2yong.global.config.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.Companion.REDIRECT_URI_PARAM_COOKIE_NAME +import com.sbl.sulmun2yong.global.util.CookieUtils +import com.sbl.sulmun2yong.user.domain.UserRole import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus import org.springframework.security.core.Authentication import org.springframework.security.web.authentication.AuthenticationSuccessHandler class CustomAuthenticationSuccessHandler( - private val frontEndBaseUrl: String, + private val frontendBaseUrl: String, + private val backendBaseUrl: String, + private val httpCookieOAuth2AuthorizationRequestRepository: HttpCookieOAuth2AuthorizationRequestRepository, ) : AuthenticationSuccessHandler { override fun onAuthenticationSuccess( request: HttpServletRequest, response: HttpServletResponse, authentication: Authentication, ) { - // 상태 코드 설정 - response.status = HttpStatus.OK.value() - val principal = authentication.principal val defaultUserProfile = if (principal is CustomOAuth2User) { @@ -27,12 +29,22 @@ class CustomAuthenticationSuccessHandler( throw IllegalArgumentException("CustomOAuth2User 타입이 아닙니다.") } + val redirectUriAfterLogin = + CookieUtils.findCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)?.value + ?: if (defaultUserProfile.role == UserRole.ROLE_ADMIN) { + backendBaseUrl + } else { + frontendBaseUrl + } + + httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response) + // 기본 프로필 쿠키 생성 val cookie = Cookie("user-profile", defaultUserProfile.toBase64Json()) cookie.path = "/" response.addCookie(cookie) // 리디렉트 - response.sendRedirect("$frontEndBaseUrl/") + response.sendRedirect(redirectUriAfterLogin) } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/error/ErrorCode.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/error/ErrorCode.kt index 3f62dda9..462c5456 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/error/ErrorCode.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/error/ErrorCode.kt @@ -12,8 +12,11 @@ enum class ErrorCode( INPUT_INVALID_VALUE(HttpStatus.BAD_REQUEST, "GL0002", "잘못된 입력입니다."), LOGIN_REQUIRED(HttpStatus.UNAUTHORIZED, "GL0003", "로그인이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "GL0004", "접근 권한이 없습니다."), + FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "GL0005", "파일은 최대 5mb까지만 업로드할 수 있습니다."), + UNCLEAN_VISITOR(HttpStatus.FORBIDDEN, "GL0006", "유효하지 않은 visitorId입니다."), // Survey (SV) + ALREADY_PARTICIPATED(HttpStatus.BAD_REQUEST, "SV0001", "이미 참여한 설문입니다."), SURVEY_NOT_FOUND(HttpStatus.NOT_FOUND, "SV0002", "설문을 찾을 수 없습니다."), INVALID_QUESTION_RESPONSE(HttpStatus.BAD_REQUEST, "SV0004", "유효하지 않은 질문 응답입니다."), INVALID_SECTION(HttpStatus.BAD_REQUEST, "SV0005", "유효하지 않은 섹션입니다."), @@ -27,8 +30,14 @@ enum class ErrorCode( SURVEY_CLOSED(HttpStatus.BAD_REQUEST, "SV0014", "응답을 받지 않는 설문입니다."), INVALID_UPDATE_SURVEY(HttpStatus.BAD_REQUEST, "SV0015", "설문 정보 갱신에 실패했습니다."), INVALID_SURVEY_ACCESS(HttpStatus.FORBIDDEN, "SV0016", "설문 접근 권한이 없습니다."), - ALREADY_PARTICIPATED(HttpStatus.BAD_REQUEST, "SV0017", "이미 참여한 설문입니다."), INVALID_SURVEY_START(HttpStatus.BAD_REQUEST, "SV0018", "설문 시작에 실패했습니다."), + INVALID_REWARD_SETTING(HttpStatus.BAD_REQUEST, "SV0019", "유효하지 않은 리워드 지급 설정입니다."), + INVALID_RESULT_DETAILS(HttpStatus.BAD_REQUEST, "SV0020", "유효하지 않은 설문 결과입니다."), + INVALID_QUESTION_FILTER(HttpStatus.BAD_REQUEST, "SV0021", "유효하지 않은 질문 필터입니다."), + INVALID_FINISHED_AT(HttpStatus.BAD_REQUEST, "SV0022", "유효하지 않은 마감일입니다."), + INVALID_SURVEY_EDIT(HttpStatus.BAD_REQUEST, "SV0023", "설문 수정 상태 변경에 실패했습니다."), + INVALID_PUBLISHED_AT(HttpStatus.BAD_REQUEST, "SV0024", "설문 마감일이 설문 공개일 보다 빠릅니다."), + INVALID_RESULT_FILTER(HttpStatus.BAD_REQUEST, "SV0025", "필터는 최대 20개 까지만 적용 가능합니다."), // Drawing (DR) INVALID_DRAWING_BOARD(HttpStatus.BAD_REQUEST, "DR0001", "유효하지 않은 추첨 보드입니다."), @@ -50,4 +59,18 @@ enum class ErrorCode( // Data (DT) INVALID_PHONE_NUMBER(HttpStatus.BAD_REQUEST, "DT0001", "유효하지 않은 전화번호입니다."), + + // File Validator (FV) + INVALID_EXTENSION(HttpStatus.BAD_REQUEST, "FV0001", "허용하지 않는 확장자입니다."), + FILE_NAME_TOO_SHORT(HttpStatus.BAD_REQUEST, "FV0003", "파일 이름이 너무 짧습니다."), + FILE_NAME_TOO_LONG(HttpStatus.BAD_REQUEST, "FV0004", "파일 이름이 너무 깁니다."), + NO_FILE_EXIST(HttpStatus.BAD_REQUEST, "FV0005", "파일이 존재하지 않습니다."), + NO_EXTENSION_EXIST(HttpStatus.BAD_REQUEST, "FV0006", "파일 확장자가 존재하지 않습니다."), + INVALID_FILE_URL(HttpStatus.BAD_REQUEST, "FV0007", "유효하지 않은 파일 주소입니다."), + + // Python Server (PY) + SURVEY_GENERATION_BY_AI_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PY0001", "AI를 활용한 설문 생성에 실패했습니다."), + TEXT_TOO_LONG(HttpStatus.BAD_REQUEST, "PY0002", "텍스트의 길이가 너무 깁니다"), + FILE_EXTENSION_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "PY0003", "지원하지 않는 파일 확장자입니다."), + FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "PY0004", "파일을 찾을 수 없습니다."), } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/error/GlobalExceptionHandler.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/error/GlobalExceptionHandler.kt index 5d104c53..6fa02f96 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/error/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/error/GlobalExceptionHandler.kt @@ -7,6 +7,7 @@ import org.springframework.security.core.AuthenticationException import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.multipart.MaxUploadSizeExceededException @RestControllerAdvice class GlobalExceptionHandler { @@ -49,4 +50,10 @@ class GlobalExceptionHandler { log.warn(e.message, e) return ErrorResponse.of(ErrorCode.INPUT_INVALID_VALUE, e.bindingResult) } + + @ExceptionHandler(MaxUploadSizeExceededException::class) + protected fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException): ErrorResponse { + log.warn(e.message, e) + return ErrorResponse.of(ErrorCode.FILE_SIZE_EXCEEDED) + } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/error/PythonServerExceptionMapper.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/error/PythonServerExceptionMapper.kt new file mode 100644 index 00000000..a62af938 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/error/PythonServerExceptionMapper.kt @@ -0,0 +1,32 @@ +package com.sbl.sulmun2yong.global.error + +import com.fasterxml.jackson.databind.ObjectMapper +import com.sbl.sulmun2yong.ai.exception.FileExtensionNotSupportedException +import com.sbl.sulmun2yong.ai.exception.FileNotFoundException +import com.sbl.sulmun2yong.ai.exception.SurveyGenerationByAIFailedException +import com.sbl.sulmun2yong.ai.exception.TextTooLongException +import org.springframework.web.client.HttpClientErrorException + +object PythonServerExceptionMapper { + private val objectMapper = ObjectMapper() + + data class ErrorDetail( + val code: String = "", + val message: String = "", + ) + + data class PythonServerException( + val detail: ErrorDetail = ErrorDetail(), + ) + + fun mapException(e: HttpClientErrorException): BusinessException { + val exception = objectMapper.readValue(e.responseBodyAsString, PythonServerException::class.java) + when (exception.detail.code) { + "PY0001" -> throw SurveyGenerationByAIFailedException() + "PY0002" -> throw TextTooLongException() + "PY0003" -> throw FileExtensionNotSupportedException() + "PY0004" -> throw FileNotFoundException() + else -> throw e + } + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddCreatedAtAtDrawingHistories.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddCreatedAtAtDrawingHistories.kt new file mode 100644 index 00000000..5a2ada21 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddCreatedAtAtDrawingHistories.kt @@ -0,0 +1,33 @@ +package com.sbl.sulmun2yong.global.migration + +import io.mongock.api.annotations.ChangeUnit +import io.mongock.api.annotations.Execution +import io.mongock.api.annotations.RollbackExecution +import org.slf4j.LoggerFactory +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update +import java.util.Date + +/** RewardHistories 컬렉션에 createdAt을 넣는 Migration Class */ +@ChangeUnit(id = "AddCreatedAtAtDrawingHistories", order = "005", author = "hunhui") +class AddCreatedAtAtDrawingHistories( + private val mongoTemplate: MongoTemplate, +) { + private val log = LoggerFactory.getLogger(AddCreatedAtAtDrawingHistories::class.java) + + @Execution + fun addCreatedAtAtDrawingHistories() { + val query = Query(Criteria.where("createdAt").`is`(null)) + val now = Date() + val update = Update().set("createdAt", now) + mongoTemplate.updateMulti(query, update, "drawingHistories") + log.info("005-AddCreatedAtAtDrawingHistories 완료") + } + + @RollbackExecution + fun rollback() { + log.warn("005-AddCreatedAtAtDrawingHistories 롤백") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddIsDeletedFieldAtSurveyDocument.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddIsDeletedFieldAtSurveyDocument.kt new file mode 100644 index 00000000..65075ea6 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddIsDeletedFieldAtSurveyDocument.kt @@ -0,0 +1,29 @@ +package com.sbl.sulmun2yong.global.migration + +import io.mongock.api.annotations.ChangeUnit +import io.mongock.api.annotations.Execution +import io.mongock.api.annotations.RollbackExecution +import org.slf4j.LoggerFactory +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update + +/** Surveys 컬렉션의 isDelete가 null인 경우 기본값 false를 넣는 Migration Class */ +@ChangeUnit(id = "AddIsDeletedFieldAtSurveyDocument", order = "001", author = "hunhui") +class AddIsDeletedFieldAtSurveyDocument { + private val log = LoggerFactory.getLogger(AddIsDeletedFieldAtSurveyDocument::class.java) + + @Execution + fun addIsDeletedField(mongoTemplate: MongoTemplate) { + val query = Query(Criteria.where("isDeleted").`is`(null)) + val update = Update().set("isDeleted", false) + mongoTemplate.updateMulti(query, update, "surveys") + log.info("001-AddIsDeletedFieldAtSurveyDocument 완료") + } + + @RollbackExecution + fun rollback() { + log.warn("001-AddIsDeletedFieldAtSurveyDocument 롤백") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddIsVisibleFieldAtSurveyDocument.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddIsVisibleFieldAtSurveyDocument.kt new file mode 100644 index 00000000..ea7fb74c --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddIsVisibleFieldAtSurveyDocument.kt @@ -0,0 +1,29 @@ +package com.sbl.sulmun2yong.global.migration + +import io.mongock.api.annotations.ChangeUnit +import io.mongock.api.annotations.Execution +import io.mongock.api.annotations.RollbackExecution +import org.slf4j.LoggerFactory +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update + +/** Surveys 컬렉션의 isVisible이 null인 경우 기본값 true를 넣는 Migration Class */ +@ChangeUnit(id = "AddIsVisibleFieldAtSurveyDocument", order = "006", author = "hunhui") +class AddIsVisibleFieldAtSurveyDocument { + private val log = LoggerFactory.getLogger(AddIsVisibleFieldAtSurveyDocument::class.java) + + @Execution + fun addIsDeletedField(mongoTemplate: MongoTemplate) { + val query = Query(Criteria.where("isVisible").`is`(null)) + val update = Update().set("isVisible", true) + mongoTemplate.updateMulti(query, update, "surveys") + log.info("006-AddIsVisibleFieldAtSurveyDocument 완료") + } + + @RollbackExecution + fun rollback() { + log.warn("006-AddIsVisibleFieldAtSurveyDocument 롤백") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddRewardSettingTypeAtSurvey.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddRewardSettingTypeAtSurvey.kt new file mode 100644 index 00000000..7ee7ec2a --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddRewardSettingTypeAtSurvey.kt @@ -0,0 +1,59 @@ +package com.sbl.sulmun2yong.global.migration + +import io.mongock.api.annotations.ChangeUnit +import io.mongock.api.annotations.Execution +import io.mongock.api.annotations.RollbackExecution +import org.slf4j.LoggerFactory +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update + +/** Surveys 컬렉션에 rewardSettingType을 넣는 Migration Class */ +@ChangeUnit(id = "AddRewardSettingTypeAtSurvey", order = "003", author = "hunhui") +class AddRewardSettingTypeAtSurvey( + private val mongoTemplate: MongoTemplate, +) { + private val log = LoggerFactory.getLogger(AddRewardSettingTypeAtSurvey::class.java) + + @Execution + fun addRewardSettingTypeAtSurvey() { + // 조건 1: finishedAt이 null이면 rewardSettingType은 NO_REWARD + val queryNoReward = Query(Criteria.where("finishedAt").exists(false)) + val updateNoReward = Update().set("rewardSettingType", "NO_REWARD") + mongoTemplate.updateMulti(queryNoReward, updateNoReward, "surveys") + + // 조건 2: finishedAt이 존재하고 targetParticipantCount가 null이면 SELF_MANAGEMENT + val querySelfManagement = + Query( + Criteria().andOperator( + Criteria.where("finishedAt").exists(true), + Criteria.where("targetParticipantCount").exists(false), + ), + ) + val updateSelfManagement = Update().set("rewardSettingType", "SELF_MANAGEMENT") + mongoTemplate.updateMulti(querySelfManagement, updateSelfManagement, "surveys") + + // 조건 3: 나머지 경우는 IMMEDIATE_DRAW + val queryImmediateDraw = + Query( + Criteria().andOperator( + Criteria.where("finishedAt").exists(true), + Criteria.where("targetParticipantCount").exists(true), + ), + ) + val updateImmediateDraw = Update().set("rewardSettingType", "IMMEDIATE_DRAW") + mongoTemplate.updateMulti(queryImmediateDraw, updateImmediateDraw, "surveys") + + log.info("003-AddRewardSettingTypeAtSurvey 완료") + } + + @RollbackExecution + fun rollback() { + val query = Query(Criteria.where("rewardSettingType").exists(true)) + val update = Update().unset("rewardSettingType") + mongoTemplate.updateMulti(query, update, "surveys") + + log.warn("003-AddRewardSettingTypeAtSurvey 롤백") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddSurveyIdAtResponses.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddSurveyIdAtResponses.kt new file mode 100644 index 00000000..ff0f4641 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/AddSurveyIdAtResponses.kt @@ -0,0 +1,64 @@ +package com.sbl.sulmun2yong.global.migration + +import io.mongock.api.annotations.ChangeUnit +import io.mongock.api.annotations.Execution +import io.mongock.api.annotations.RollbackExecution +import org.bson.types.Binary +import org.slf4j.LoggerFactory +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update + +/** Responses 컬렉션에 surveyId를 넣는 Migration Class */ +@ChangeUnit(id = "AddSurveyIdAtResponses", order = "002", author = "hunhui") +class AddSurveyIdAtResponses( + private val mongoTemplate: MongoTemplate, +) { + private val log = LoggerFactory.getLogger(AddSurveyIdAtResponses::class.java) + + @Execution + fun addSurveyIdAtResponses() { + val surveys = mongoTemplate.find(Query(), Map::class.java, "surveys") + val questionIdSurveyIdMap = + surveys + .flatMap { survey -> + (survey["sections"] as List>).flatMap { section -> + (section["questions"] as List>).map { question -> + question["questionId"] to survey["_id"] + } + } + }.toMap() + + val responseDocuments = mongoTemplate.find(Query(), Map::class.java, "responses") + + responseDocuments.forEach { response -> + val questionId = response["questionId"] as? Binary + if (questionId != null) { + val surveyId = questionIdSurveyIdMap[questionId] + if (surveyId != null) { + val update = Update().set("surveyId", surveyId) + val updateQuery = Query(Criteria.where("_id").`is`(response["_id"])) + mongoTemplate.updateFirst(updateQuery, update, "responses") + } else { + log.warn("Response ${response["_id"]}에 해당하는 surveyId를 찾을 수 없습니다.") + } + } else { + log.warn("Response ${response["_id"]}에 questionId가 없습니다.") + } + } + + log.info("002-AddSurveyIdAtResponses 완료") + } + + @RollbackExecution + fun rollback() { + mongoTemplate.updateMulti( + Query(Criteria.where("surveyId").exists(true)), + Update().unset("surveyId"), + "responses", + ) + + log.warn("002-AddSurveyIdAtResponses 롤백") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/migration/UpdateFinishedAtAtSurvey.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/UpdateFinishedAtAtSurvey.kt new file mode 100644 index 00000000..f3df326b --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/migration/UpdateFinishedAtAtSurvey.kt @@ -0,0 +1,49 @@ +package com.sbl.sulmun2yong.global.migration + +import io.mongock.api.annotations.ChangeUnit +import io.mongock.api.annotations.Execution +import io.mongock.api.annotations.RollbackExecution +import org.slf4j.LoggerFactory +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update +import java.time.ZoneId +import java.util.Date + +/** Surveys 컬렉션의 finishedAt의 분 단위 이하를 0으로 수정하는 Migration Class */ +@ChangeUnit(id = "UpdateFinishedAtAtSurvey", order = "004", author = "hunhui") +class UpdateFinishedAtAtSurvey( + private val mongoTemplate: MongoTemplate, +) { + private val log = LoggerFactory.getLogger(UpdateFinishedAtAtSurvey::class.java) + + @Execution + fun updateFinishedAtAtSurvey() { + val surveys = mongoTemplate.find(Query(), Map::class.java, "surveys") + + surveys.forEach { survey -> + survey["finishedAt"]?.let { + val updatedFinishedAt = + (it as Date) + .toInstant() + .atZone(ZoneId.systemDefault()) + .withMinute(0) + .withSecond(0) + .withNano(0) + .toInstant() + + val update = Update().set("finishedAt", updatedFinishedAt) + + val updateQuery = Query(Criteria.where("_id").`is`(survey["_id"])) + mongoTemplate.updateFirst(updateQuery, update, "surveys") + } + } + log.info("004-UpdateFinishedAtAtSurvey 완료") + } + + @RollbackExecution + fun rollback() { + log.warn("004-UpdateFinishedAtAtSurvey 롤백") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/CookieUtils.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/CookieUtils.kt new file mode 100644 index 00000000..5db23b6d --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/CookieUtils.kt @@ -0,0 +1,75 @@ +package com.sbl.sulmun2yong.global.util + +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.util.SerializationUtils +import java.util.Base64 + +object CookieUtils { + fun getCookie( + request: HttpServletRequest, + name: String, + ): Cookie { + val cookies: Array = request.cookies + + return cookies.firstOrNull { it.name == name } + ?: throw IllegalArgumentException("존재하지 않는 쿠키입니다") + } + + fun findCookie( + request: HttpServletRequest, + name: String, + ): Cookie? { + val cookies: Array = request.cookies + return cookies.firstOrNull { it.name == name } + } + + fun addCookie( + response: HttpServletResponse, + name: String, + value: String, + maxAge: Int, + ) { + val cookie = Cookie(name, value) + cookie.path = "/" + cookie.isHttpOnly = true + cookie.maxAge = maxAge + response.addCookie(cookie) + } + + fun deleteCookie( + request: HttpServletRequest, + response: HttpServletResponse, + name: String, + ) { + val cookies: Array? = request.cookies + if (cookies.isNullOrEmpty()) { + return + } + + for (cookie in cookies) { + if (cookie.name == name) { + cookie.value = "" + cookie.path = "/" + cookie.maxAge = 0 + response.addCookie(cookie) + } + } + } + + fun serialize(cookieObject: Any): String = + Base64 + .getUrlEncoder() + .encodeToString(SerializationUtils.serialize(cookieObject)) + + fun deserialize( + cookie: Cookie, + cls: Class, + ): T = + cls.cast( + SerializationUtils.deserialize( + Base64.getUrlDecoder().decode(cookie.getValue()), + ), + ) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/fingerprint/FingerprintApi.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/FingerprintApi.kt similarity index 58% rename from src/main/kotlin/com/sbl/sulmun2yong/global/fingerprint/FingerprintApi.kt rename to src/main/kotlin/com/sbl/sulmun2yong/global/util/FingerprintApi.kt index aab382ff..014aa85a 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/fingerprint/FingerprintApi.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/FingerprintApi.kt @@ -1,11 +1,15 @@ package com.sbl.sulmun2yong.global.fingerprint import com.fingerprint.api.FingerprintApi +import com.fingerprint.model.BotdDetectionResult +import com.fingerprint.model.EventResponse +import com.fingerprint.model.ProductsResponse import com.fingerprint.model.Response import com.fingerprint.model.ResponseVisits import com.fingerprint.sdk.ApiClient import com.fingerprint.sdk.Configuration import com.fingerprint.sdk.Region +import com.sbl.sulmun2yong.global.util.exception.UncleanVisitorException import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @@ -26,6 +30,7 @@ class FingerprintApi( if (visits.isNullOrEmpty()) { throw Exception("Invalid visitorId") } + checkIsVisitorClean(visits) } private fun getVisits(visitorId: String): MutableList? { @@ -33,8 +38,17 @@ class FingerprintApi( return response.visits } -// fun getEvent(requestId: String) { -// val response: EventResponse = api.getEvent(requestId) -// println(response.products.toString()) -// } + private fun getEvent(requestId: String): ProductsResponse { + val response: EventResponse = api.getEvent(requestId) + return response.products + } + + private fun checkIsVisitorClean(visits: MutableList) { + val product = getEvent(visits[0].requestId) + if (product.tampering.data.result == true || + product.botd.data.bot.result !== BotdDetectionResult.ResultEnum.NOT_DETECTED + ) { + throw UncleanVisitorException() + } + } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/RandomNicknameGenerator.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/RandomNicknameGenerator.kt index 45a39b30..17c41da1 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/util/RandomNicknameGenerator.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/RandomNicknameGenerator.kt @@ -2,133 +2,131 @@ package com.sbl.sulmun2yong.global.util import kotlin.random.Random -class RandomNicknameGenerator { - companion object { - fun generate(): String { - val positiveAdjective = getRandomPositiveAdjective() - val animalName = getRandomAnimalName() - return "$positiveAdjective $animalName" - } +object RandomNicknameGenerator { + fun generate(): String { + val positiveAdjective = getRandomPositiveAdjective() + val animalName = getRandomAnimalName() + return "$positiveAdjective $animalName" + } - private fun getRandomPositiveAdjective(): String { - val randomIndex = Random.nextInt(positiveAdjectives.size) - return positiveAdjectives[randomIndex] - } + private fun getRandomPositiveAdjective(): String { + val randomIndex = Random.nextInt(positiveAdjectives.size) + return positiveAdjectives[randomIndex] + } - private fun getRandomAnimalName(): String { - val randomIndex = Random.nextInt(animalNames.size) - return animalNames[randomIndex] - } + private fun getRandomAnimalName(): String { + val randomIndex = Random.nextInt(animalNames.size) + return animalNames[randomIndex] + } - private val positiveAdjectives = - listOf( - "멋진", - "아름다운", - "사랑스러운", - "기분 좋은", - "활기찬", - "행복한", - "용감한", - "현명한", - "똑똑한", - "친절한", - "따뜻한", - "차분한", - "건강한", - "잘생긴", - "매력적인", - "유쾌한", - "긍정적인", - "발랄한", - "자유로운", - "훌륭한", - "빛나는", - "깨끗한", - "상쾌한", - "풍부한", - "고운", - "순수한", - "평온한", - "친절한", - "재치 있는", - "유머러스한", - "사려 깊은", - "활발한", - "열정적인", - "끈기 있는", - "적극적인", - "신나는", - "흥미로운", - "놀라운", - "기대되는", - "즐거운", - "기쁜", - "자랑스러운", - "감동적인", - "성실한", - "충실한", - "헌신적인", - "열정있는", - ) + private val positiveAdjectives = + listOf( + "멋진", + "아름다운", + "사랑스러운", + "기분 좋은", + "활기찬", + "행복한", + "용감한", + "현명한", + "똑똑한", + "친절한", + "따뜻한", + "차분한", + "건강한", + "잘생긴", + "매력적인", + "유쾌한", + "긍정적인", + "발랄한", + "자유로운", + "훌륭한", + "빛나는", + "깨끗한", + "상쾌한", + "풍부한", + "고운", + "순수한", + "평온한", + "친절한", + "재치 있는", + "유머러스한", + "사려 깊은", + "활발한", + "열정적인", + "끈기 있는", + "적극적인", + "신나는", + "흥미로운", + "놀라운", + "기대되는", + "즐거운", + "기쁜", + "자랑스러운", + "감동적인", + "성실한", + "충실한", + "헌신적인", + "열정있는", + ) - val animalNames = - listOf( - "고양이", - "강아지", - "코끼리", - "기린", - "사자", - "호랑이", - "곰", - "늑대", - "여우", - "토끼", - "사슴", - "원숭이", - "판다", - "코알라", - "캥거루", - "말", - "소", - "양", - "염소", - "닭", - "오리", - "거위", - "공작", - "앵무새", - "독수리", - "매", - "올빼미", - "참새", - "비둘기", - "까마귀", - "까치", - "두루미", - "황새", - "치타", - "하마", - "악어", - "두더지", - "수달", - "다람쥐", - "너구리", - "사막여우", - "라쿤", - "뱀", - "도마뱀", - "거북이", - "펭귄", - "돌고래", - "용", - "표범", - "고래", - "부엉이", - "타조", - "코뿔소", - "물개", - "고슴도치", - "카멜레온", - ) - } + private val animalNames = + listOf( + "고양이", + "강아지", + "코끼리", + "기린", + "사자", + "호랑이", + "곰", + "늑대", + "여우", + "토끼", + "사슴", + "원숭이", + "판다", + "코알라", + "캥거루", + "말", + "소", + "양", + "염소", + "닭", + "오리", + "거위", + "공작", + "앵무새", + "독수리", + "매", + "올빼미", + "참새", + "비둘기", + "까마귀", + "까치", + "두루미", + "황새", + "치타", + "하마", + "악어", + "두더지", + "수달", + "다람쥐", + "너구리", + "사막여우", + "라쿤", + "뱀", + "도마뱀", + "거북이", + "펭귄", + "돌고래", + "용", + "표범", + "고래", + "부엉이", + "타조", + "코뿔소", + "물개", + "고슴도치", + "카멜레온", + ) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/ResetSession.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/ResetSession.kt index 064dd2e7..f21d49db 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/util/ResetSession.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/ResetSession.kt @@ -4,25 +4,23 @@ import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -class ResetSession { - companion object { - fun reset( - request: HttpServletRequest, - response: HttpServletResponse, - ) { - // 세션 무효화 - request.session.invalidate() +object ResetSession { + fun reset( + request: HttpServletRequest, + response: HttpServletResponse, + ) { + // 세션 무효화 + request.session.invalidate() - // 쿠키 삭제 - val expiredJsessionIdCookie = Cookie("JSESSIONID", null) - expiredJsessionIdCookie.path = "/" - expiredJsessionIdCookie.maxAge = 0 - response.addCookie(expiredJsessionIdCookie) + // 쿠키 삭제 + val expiredJsessionIdCookie = Cookie("JSESSIONID", null) + expiredJsessionIdCookie.path = "/" + expiredJsessionIdCookie.maxAge = 0 + response.addCookie(expiredJsessionIdCookie) - val expiredUserProfileCookie = Cookie("user-profile", null) - expiredUserProfileCookie.path = "/" - expiredUserProfileCookie.maxAge = 0 - response.addCookie(expiredUserProfileCookie) - } + val expiredUserProfileCookie = Cookie("user-profile", null) + expiredUserProfileCookie.path = "/" + expiredUserProfileCookie.maxAge = 0 + response.addCookie(expiredUserProfileCookie) } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/SessionRegistryCleaner.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/SessionRegistryCleaner.kt index a2f5860f..dd30c789 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/global/util/SessionRegistryCleaner.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/SessionRegistryCleaner.kt @@ -3,17 +3,15 @@ package com.sbl.sulmun2yong.global.util import org.springframework.security.core.Authentication import org.springframework.security.core.session.SessionRegistry -class SessionRegistryCleaner { - companion object { - fun removeSessionByAuthentication( - sessionRegistry: SessionRegistry, - authentication: Authentication, - ) { - sessionRegistry - .getAllSessions(authentication.principal, false) - .forEach { sessionInformation -> - sessionRegistry.removeSessionInformation(sessionInformation.sessionId) - } - } +object SessionRegistryCleaner { + fun removeSessionByAuthentication( + sessionRegistry: SessionRegistry, + authentication: Authentication, + ) { + sessionRegistry + .getAllSessions(authentication.principal, false) + .forEach { sessionInformation -> + sessionRegistry.removeSessionInformation(sessionInformation.sessionId) + } } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/FileNameTooLongException.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/FileNameTooLongException.kt new file mode 100644 index 00000000..215b9e0f --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/FileNameTooLongException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.global.util.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class FileNameTooLongException : BusinessException(ErrorCode.FILE_NAME_TOO_LONG) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/FileNameTooShortException.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/FileNameTooShortException.kt new file mode 100644 index 00000000..5ae05456 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/FileNameTooShortException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.global.util.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class FileNameTooShortException : BusinessException(ErrorCode.FILE_NAME_TOO_SHORT) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/InvalidExtensionException.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/InvalidExtensionException.kt new file mode 100644 index 00000000..c33496e9 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/InvalidExtensionException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.global.util.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidExtensionException : BusinessException(ErrorCode.INVALID_EXTENSION) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/InvalidFileUrlException.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/InvalidFileUrlException.kt new file mode 100644 index 00000000..00abd470 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/InvalidFileUrlException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.global.util.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidFileUrlException : BusinessException(ErrorCode.INVALID_FILE_URL) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/NoExtensionExistException.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/NoExtensionExistException.kt new file mode 100644 index 00000000..393630c1 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/NoExtensionExistException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.global.util.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class NoExtensionExistException : BusinessException(ErrorCode.NO_EXTENSION_EXIST) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/NoFileExistException.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/NoFileExistException.kt new file mode 100644 index 00000000..3d18ba8e --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/NoFileExistException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.global.util.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class NoFileExistException : BusinessException(ErrorCode.NO_FILE_EXIST) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/UncleanVisitorException.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/UncleanVisitorException.kt new file mode 100644 index 00000000..6d336f0e --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/exception/UncleanVisitorException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.global.util.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class UncleanVisitorException : BusinessException(ErrorCode.UNCLEAN_VISITOR) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/validator/FileUploadValidator.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/validator/FileUploadValidator.kt new file mode 100644 index 00000000..7a958ac2 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/validator/FileUploadValidator.kt @@ -0,0 +1,55 @@ +package com.sbl.sulmun2yong.global.util.validator + +import com.sbl.sulmun2yong.global.util.exception.FileNameTooLongException +import com.sbl.sulmun2yong.global.util.exception.FileNameTooShortException +import com.sbl.sulmun2yong.global.util.exception.InvalidExtensionException +import com.sbl.sulmun2yong.global.util.exception.NoExtensionExistException +import com.sbl.sulmun2yong.global.util.exception.NoFileExistException +import org.springframework.web.multipart.MultipartFile + +class FileUploadValidator( + private val maxFileNameLength: Int, + private val allowedExtensions: MutableList, + private val allowedContentTypes: MutableList, +) { + companion object { + fun from( + maxFileNameLength: Int, + allowedExtensions: String, + allowedContentTypes: String, + ): FileUploadValidator { + val extensions = allowedExtensions.split(",").toMutableList() + val contentTypes = allowedContentTypes.split(",").toMutableList() + + return FileUploadValidator( + maxFileNameLength, + extensions, + contentTypes, + ) + } + } + + fun validateFileOf(file: MultipartFile) { + fun checkIsAllowedExtensionOrType(contentType: String): Boolean = + allowedExtensions.contains(contentType) || allowedContentTypes.any { contentType.startsWith(it) } + + val fileName: String? = file.originalFilename + val contentType = file.contentType + + if (file.isEmpty) { + throw NoFileExistException() + } + if (contentType.isNullOrBlank()) { + throw NoExtensionExistException() + } + if (fileName.isNullOrBlank()) { + throw FileNameTooShortException() + } + if (fileName.length > maxFileNameLength) { + throw FileNameTooLongException() + } + if (!checkIsAllowedExtensionOrType(contentType)) { + throw InvalidExtensionException() + } + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/global/util/validator/FileUrlValidator.kt b/src/main/kotlin/com/sbl/sulmun2yong/global/util/validator/FileUrlValidator.kt new file mode 100644 index 00000000..d21c98e1 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/global/util/validator/FileUrlValidator.kt @@ -0,0 +1,24 @@ +package com.sbl.sulmun2yong.global.util.validator + +import com.sbl.sulmun2yong.global.util.exception.InvalidExtensionException +import com.sbl.sulmun2yong.global.util.exception.InvalidFileUrlException + +class FileUrlValidator( + private val cloudFrontBaseUrl: String, +) { + companion object { + fun of(cloudFrontBaseUrl: String): FileUrlValidator = FileUrlValidator(cloudFrontBaseUrl) + } + + fun validateFileUrlOf( + fileUrl: String, + allowedExtensions: MutableList, + ) { + if (fileUrl.startsWith(cloudFrontBaseUrl).not()) { + throw InvalidFileUrlException() + } + if (allowedExtensions.none { fileUrl.endsWith(it) }) { + throw InvalidExtensionException() + } + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ParticipantAdapter.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ParticipantAdapter.kt index 27a4cfb9..d3c8686f 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ParticipantAdapter.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ParticipantAdapter.kt @@ -11,16 +11,21 @@ import java.util.UUID class ParticipantAdapter( val participantRepository: ParticipantRepository, ) { - fun saveParticipant(participant: Participant) { - participantRepository.save(ParticipantDocument.of(participant)) + fun insert(participant: Participant) { + participantRepository.insert(ParticipantDocument.of(participant)) } - fun getParticipant(id: UUID): Participant = + fun getByParticipantId(id: UUID): Participant = participantRepository .findById(id) .orElseThrow { InvalidParticipantException() } .toDomain() + fun findBySurveyId(surveyId: UUID): List = + participantRepository + .findBySurveyId(surveyId) + .map { it.toDomain() } + fun findBySurveyIdAndVisitorId( surveyId: UUID, visitorId: String, diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ResponseAdapter.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ResponseAdapter.kt index ad23b1a5..edda470b 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ResponseAdapter.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/ResponseAdapter.kt @@ -1,6 +1,9 @@ package com.sbl.sulmun2yong.survey.adapter import com.sbl.sulmun2yong.survey.domain.response.SurveyResponse +import com.sbl.sulmun2yong.survey.domain.result.QuestionResult +import com.sbl.sulmun2yong.survey.domain.result.ResultDetails +import com.sbl.sulmun2yong.survey.domain.result.SurveyResult import com.sbl.sulmun2yong.survey.entity.ResponseDocument import com.sbl.sulmun2yong.survey.repository.ResponseRepository import org.springframework.stereotype.Component @@ -10,11 +13,11 @@ import java.util.UUID class ResponseAdapter( private val responseRepository: ResponseRepository, ) { - fun saveSurveyResponse( + fun insertSurveyResponse( surveyResponse: SurveyResponse, participantId: UUID, ) { - responseRepository.saveAll(surveyResponse.toDocuments(participantId)) + responseRepository.insert(surveyResponse.toDocuments(participantId)) } private fun SurveyResponse.toDocuments(participantId: UUID): List = @@ -24,10 +27,39 @@ class ResponseAdapter( ResponseDocument( id = UUID.randomUUID(), participantId = participantId, + surveyId = this.surveyId, questionId = questionResponse.questionId, content = it.content, ) } } } + + fun getSurveyResult( + surveyId: UUID, + participantId: UUID?, + ): SurveyResult { + val responses = + if (participantId != null) { + responseRepository.findBySurveyIdAndParticipantId(surveyId, participantId) + } else { + responseRepository.findBySurveyId(surveyId) + } + // TODO: 추후 DB Level에서 처리하도록 변경 + 필터링을 동적쿼리로 하도록 변경 + val groupingResponses = responses.groupBy { it.questionId }.values + return SurveyResult(questionResults = groupingResponses.map { it.toDomain() }) + } + + private fun List.toDomain() = + QuestionResult( + questionId = first().questionId, + resultDetails = + this.groupBy { it.participantId }.map { + ResultDetails( + participantId = it.key, + contents = it.value.map { responseDocument -> responseDocument.content }, + ) + }, + contents = this.map { it.content }.toSortedSet(), + ) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/SurveyAdapter.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/SurveyAdapter.kt index 2ccaaf80..2ea7cf02 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/SurveyAdapter.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/adapter/SurveyAdapter.kt @@ -2,6 +2,7 @@ package com.sbl.sulmun2yong.survey.adapter import com.sbl.sulmun2yong.survey.domain.Survey import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.dto.request.MySurveySortType import com.sbl.sulmun2yong.survey.dto.request.SurveySortType import com.sbl.sulmun2yong.survey.entity.SurveyDocument import com.sbl.sulmun2yong.survey.exception.SurveyNotFoundException @@ -11,6 +12,7 @@ import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.stereotype.Component +import java.util.Date import java.util.UUID @Component @@ -24,12 +26,13 @@ class SurveyAdapter( isAsc: Boolean, ): Page { val pageRequest = PageRequest.of(page, size, getSurveySort(sortType, isAsc)) - val surveyDocuments = surveyRepository.findByStatus(SurveyStatus.IN_PROGRESS, pageRequest) + val surveyDocuments = surveyRepository.findByStatusAndIsVisibleTrueAndIsDeletedFalse(SurveyStatus.IN_PROGRESS, pageRequest) val surveys = surveyDocuments.content.map { it.toDomain() } return PageImpl(surveys, pageRequest, surveyDocuments.totalElements) } - fun getSurvey(surveyId: UUID) = surveyRepository.findById(surveyId).orElseThrow { SurveyNotFoundException() }.toDomain() + fun getSurvey(surveyId: UUID) = + surveyRepository.findByIdAndIsDeletedFalse(surveyId).orElseThrow { SurveyNotFoundException() }.toDomain() private fun getSurveySort( sortType: SurveySortType, @@ -45,6 +48,35 @@ class SurveyAdapter( } fun save(survey: Survey) { - surveyRepository.save(SurveyDocument.from(survey)) + val previousSurveyDocument = surveyRepository.findByIdAndIsDeletedFalse(survey.id) + val surveyDocument = SurveyDocument.from(survey) + // 기존 설문을 업데이트하는 경우, createdAt을 유지 + if (previousSurveyDocument.isPresent) surveyDocument.createdAt = previousSurveyDocument.get().createdAt + surveyRepository.save(surveyDocument) + } + + fun getByIdAndMakerId( + surveyId: UUID, + makerId: UUID, + ) = surveyRepository.findByIdAndMakerIdAndIsDeletedFalse(surveyId, makerId).orElseThrow { SurveyNotFoundException() }.toDomain() + + fun getMyPageSurveysInfo( + makerId: UUID, + status: SurveyStatus?, + sortType: MySurveySortType, + ) = surveyRepository.findSurveysWithResponseCount(makerId, status, sortType) + + fun delete( + surveyId: UUID, + makerId: UUID, + ) { + val isSuccess = surveyRepository.softDelete(surveyId, makerId) + if (!isSuccess) throw SurveyNotFoundException() + } + + fun findFinishTargets(now: Date) = surveyRepository.findFinishTargets(now).map { it.toDomain() } + + fun saveAll(surveys: List) { + surveyRepository.saveAll(surveys.map { SurveyDocument.from(it) }) } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyInfoController.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyInfoController.kt index 4c849699..4253d99a 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyInfoController.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyInfoController.kt @@ -1,7 +1,11 @@ package com.sbl.sulmun2yong.survey.controller +import com.sbl.sulmun2yong.global.annotation.LoginUser import com.sbl.sulmun2yong.survey.controller.doc.SurveyInfoApiDoc +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.dto.request.MySurveySortType import com.sbl.sulmun2yong.survey.dto.request.SurveySortType +import com.sbl.sulmun2yong.survey.dto.response.MyPageSurveysResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyInfoResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyListResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyProgressInfoResponse @@ -16,7 +20,9 @@ import java.util.UUID @RestController @RequestMapping("/api/v1/surveys") -class SurveyInfoController(private val surveyInfoService: SurveyInfoService) : SurveyInfoApiDoc { +class SurveyInfoController( + private val surveyInfoService: SurveyInfoService, +) : SurveyInfoApiDoc { @GetMapping("/list") override fun getSurveysWithPagination( @RequestParam(defaultValue = "10") size: Int, @@ -37,4 +43,11 @@ class SurveyInfoController(private val surveyInfoService: SurveyInfoService) : S override fun getSurveyProgressInfo( @PathVariable("survey-id") surveyId: UUID, ): ResponseEntity = ResponseEntity.ok(surveyInfoService.getSurveyProgressInfo(surveyId)) + + @GetMapping("/my-page") + override fun getMyPageSurveys( + @LoginUser userId: UUID, + @RequestParam status: SurveyStatus?, + @RequestParam(defaultValue = "LAST_MODIFIED") sortType: MySurveySortType, + ): ResponseEntity = ResponseEntity.ok(surveyInfoService.getMyPageSurveys(userId, status, sortType)) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyManagementController.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyManagementController.kt index 1719a4f0..649a652c 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyManagementController.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyManagementController.kt @@ -1,11 +1,11 @@ package com.sbl.sulmun2yong.survey.controller -import com.sbl.sulmun2yong.drawing.adapter.DrawingBoardAdapter import com.sbl.sulmun2yong.global.annotation.LoginUser import com.sbl.sulmun2yong.survey.controller.doc.SurveyManagementApiDoc import com.sbl.sulmun2yong.survey.dto.request.SurveySaveRequest import com.sbl.sulmun2yong.survey.service.SurveyManagementService import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable @@ -20,7 +20,6 @@ import java.util.UUID @RequestMapping("/api/v1/surveys/workbench") class SurveyManagementController( private val surveyManagementService: SurveyManagementService, - private val drawingBoardAdapter: DrawingBoardAdapter, ) : SurveyManagementApiDoc { @PostMapping("/create") override fun createSurvey( @@ -51,4 +50,22 @@ class SurveyManagementController( @PathVariable("surveyId") surveyId: UUID, @LoginUser id: UUID, ) = ResponseEntity.ok(surveyManagementService.startSurvey(surveyId = surveyId, makerId = id)) + + @PatchMapping("/edit/{surveyId}") + override fun editSurvey( + @PathVariable("surveyId") surveyId: UUID, + @LoginUser id: UUID, + ) = ResponseEntity.ok(surveyManagementService.editSurvey(surveyId = surveyId, makerId = id)) + + @PatchMapping("/finish/{surveyId}") + override fun finishSurvey( + @PathVariable("surveyId") surveyId: UUID, + @LoginUser id: UUID, + ) = ResponseEntity.ok(surveyManagementService.finishSurvey(surveyId = surveyId, makerId = id)) + + @DeleteMapping("/delete/{surveyId}") + override fun deleteSurvey( + @PathVariable("surveyId") surveyId: UUID, + @LoginUser id: UUID, + ) = ResponseEntity.ok(surveyManagementService.deleteSurvey(surveyId = surveyId, makerId = id)) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyParticipantController.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyParticipantController.kt new file mode 100644 index 00000000..ee843c92 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyParticipantController.kt @@ -0,0 +1,23 @@ +package com.sbl.sulmun2yong.survey.controller + +import com.sbl.sulmun2yong.global.annotation.LoginUser +import com.sbl.sulmun2yong.survey.controller.doc.SurveyParticipantApiDoc +import com.sbl.sulmun2yong.survey.service.SurveyParticipantService +import org.springframework.http.ResponseEntity +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 +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/surveys/participants") +class SurveyParticipantController( + private val surveyParticipantService: SurveyParticipantService, +) : SurveyParticipantApiDoc { + @GetMapping("/{survey-id}") + override fun getSurveyParticipants( + @PathVariable("survey-id") surveyId: UUID, + @LoginUser userId: UUID, + ) = ResponseEntity.ok(surveyParticipantService.getSurveyParticipants(surveyId, userId)) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyResponseController.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyResponseController.kt index 2964be41..5900f69f 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyResponseController.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyResponseController.kt @@ -3,7 +3,6 @@ package com.sbl.sulmun2yong.survey.controller import com.sbl.sulmun2yong.global.annotation.IsAdmin import com.sbl.sulmun2yong.survey.controller.doc.SurveyResponseApiDoc import com.sbl.sulmun2yong.survey.dto.request.SurveyResponseRequest -import com.sbl.sulmun2yong.survey.dto.response.SurveyParticipantResponse import com.sbl.sulmun2yong.survey.service.SurveyResponseService import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -24,8 +23,5 @@ class SurveyResponseController( @PathVariable("survey-id") surveyId: UUID, @Valid @RequestBody surveyResponseRequest: SurveyResponseRequest, @IsAdmin isAdmin: Boolean, - ): ResponseEntity = - ResponseEntity.ok( - surveyResponseService.responseToSurvey(surveyId, surveyResponseRequest, isAdmin), - ) + ) = ResponseEntity.ok(surveyResponseService.responseToSurvey(surveyId, surveyResponseRequest, isAdmin)) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyResultController.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyResultController.kt new file mode 100644 index 00000000..e1382b47 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/SurveyResultController.kt @@ -0,0 +1,28 @@ +package com.sbl.sulmun2yong.survey.controller + +import com.sbl.sulmun2yong.global.annotation.LoginUser +import com.sbl.sulmun2yong.survey.controller.doc.SurveyResultApiDoc +import com.sbl.sulmun2yong.survey.dto.request.SurveyResultRequest +import com.sbl.sulmun2yong.survey.service.SurveyResultService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/surveys/result") +class SurveyResultController( + private val surveyResultService: SurveyResultService, +) : SurveyResultApiDoc { + @PostMapping("/{survey-id}") + override fun getSurveyResult( + @PathVariable("survey-id") surveyId: UUID, + @LoginUser id: UUID, + @RequestBody surveyResultRequest: SurveyResultRequest, + @RequestParam participantId: UUID?, + ) = ResponseEntity.ok(surveyResultService.getSurveyResult(surveyId, id, surveyResultRequest, participantId)) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyInfoApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyInfoApiDoc.kt index cf24096c..6dedcdcd 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyInfoApiDoc.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyInfoApiDoc.kt @@ -1,6 +1,10 @@ package com.sbl.sulmun2yong.survey.controller.doc +import com.sbl.sulmun2yong.global.annotation.LoginUser +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.dto.request.MySurveySortType import com.sbl.sulmun2yong.survey.dto.request.SurveySortType +import com.sbl.sulmun2yong.survey.dto.response.MyPageSurveysResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyInfoResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyListResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyProgressInfoResponse @@ -34,4 +38,12 @@ interface SurveyInfoApiDoc { fun getSurveyProgressInfo( @PathVariable("survey-id") surveyId: UUID, ): ResponseEntity + + @Operation(summary = "마이페이지 설문 목록 조회") + @GetMapping("/my-page") + fun getMyPageSurveys( + @LoginUser userId: UUID, + @RequestParam status: SurveyStatus?, + @RequestParam(defaultValue = "LAST_MODIFIED") sortType: MySurveySortType, + ): ResponseEntity } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyManagementApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyManagementApiDoc.kt index d2454975..04a8f075 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyManagementApiDoc.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyManagementApiDoc.kt @@ -7,6 +7,7 @@ import com.sbl.sulmun2yong.survey.dto.response.SurveyMakeInfoResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable @@ -44,4 +45,25 @@ interface SurveyManagementApiDoc { @PathVariable("surveyId") surveyId: UUID, @LoginUser id: UUID, ): ResponseEntity + + @Operation(summary = "설문 수정 API") + @PatchMapping("/edit/{surveyId}") + fun editSurvey( + @PathVariable("surveyId") surveyId: UUID, + @LoginUser id: UUID, + ): ResponseEntity + + @Operation(summary = "설문 종료 API") + @PatchMapping("/finish/{surveyId}") + fun finishSurvey( + @PathVariable("surveyId") surveyId: UUID, + @LoginUser id: UUID, + ): ResponseEntity + + @Operation(summary = "설문 삭제 API") + @DeleteMapping("/delete/{surveyId}") + fun deleteSurvey( + @PathVariable("surveyId") surveyId: UUID, + @LoginUser id: UUID, + ): ResponseEntity } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyParticipantApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyParticipantApiDoc.kt new file mode 100644 index 00000000..9a56ae57 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyParticipantApiDoc.kt @@ -0,0 +1,20 @@ +package com.sbl.sulmun2yong.survey.controller.doc + +import com.sbl.sulmun2yong.global.annotation.LoginUser +import com.sbl.sulmun2yong.survey.dto.response.ParticipantsInfoListResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import java.util.UUID + +@Tag(name = "SurveyParticipant", description = "설문 참가자 관련 API") +interface SurveyParticipantApiDoc { + @Operation(summary = "참가자 목록") + @PostMapping + fun getSurveyParticipants( + @PathVariable("survey-id") surveyId: UUID, + @LoginUser id: UUID, + ): ResponseEntity +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyResultApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyResultApiDoc.kt new file mode 100644 index 00000000..ae1f098e --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/controller/doc/SurveyResultApiDoc.kt @@ -0,0 +1,25 @@ +package com.sbl.sulmun2yong.survey.controller.doc + +import com.sbl.sulmun2yong.global.annotation.LoginUser +import com.sbl.sulmun2yong.survey.dto.request.SurveyResultRequest +import com.sbl.sulmun2yong.survey.dto.response.SurveyResultResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import java.util.UUID + +@Tag(name = "SurveyResult", description = "설문 결과 관련 API") +interface SurveyResultApiDoc { + @Operation(summary = "설문 결과 조회") + @PostMapping("/{survey-id}") + fun getSurveyResult( + @PathVariable("survey-id") surveyId: UUID, + @LoginUser id: UUID, + @RequestBody surveyResultRequest: SurveyResultRequest, + @RequestParam participantId: UUID?, + ): ResponseEntity +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/InvalidSurveyEditException.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/InvalidSurveyEditException.kt new file mode 100644 index 00000000..f1a2c30e --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/InvalidSurveyEditException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.survey.domain + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidSurveyEditException : BusinessException(ErrorCode.INVALID_SURVEY_EDIT) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Participant.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Participant.kt index e9b982a5..12edb444 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Participant.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Participant.kt @@ -1,5 +1,6 @@ package com.sbl.sulmun2yong.survey.domain +import java.util.Date import java.util.UUID data class Participant( @@ -7,12 +8,13 @@ data class Participant( val visitorId: String, val surveyId: UUID, val userId: UUID?, + val createdAt: Date, ) { companion object { fun create( visitorId: String, surveyId: UUID, userId: UUID?, - ) = Participant(UUID.randomUUID(), visitorId, surveyId, userId) + ) = Participant(UUID.randomUUID(), visitorId, surveyId, userId, Date()) } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Survey.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Survey.kt index a60ba9c9..c61b5d87 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Survey.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Survey.kt @@ -2,51 +2,52 @@ package com.sbl.sulmun2yong.survey.domain import com.sbl.sulmun2yong.global.util.DateUtil import com.sbl.sulmun2yong.survey.domain.response.SurveyResponse +import com.sbl.sulmun2yong.survey.domain.reward.ImmediateDrawSetting +import com.sbl.sulmun2yong.survey.domain.reward.NoRewardSetting +import com.sbl.sulmun2yong.survey.domain.reward.RewardSetting +import com.sbl.sulmun2yong.survey.domain.reward.SelfManagementSetting import com.sbl.sulmun2yong.survey.domain.section.Section import com.sbl.sulmun2yong.survey.domain.section.SectionId import com.sbl.sulmun2yong.survey.domain.section.SectionIds +import com.sbl.sulmun2yong.survey.exception.InvalidPublishedAtException import com.sbl.sulmun2yong.survey.exception.InvalidSurveyException import com.sbl.sulmun2yong.survey.exception.InvalidSurveyResponseException import com.sbl.sulmun2yong.survey.exception.InvalidSurveyStartException import com.sbl.sulmun2yong.survey.exception.InvalidUpdateSurveyException -import java.time.LocalDateTime -import java.time.ZoneId +import com.sbl.sulmun2yong.survey.exception.SurveyClosedException import java.util.Date import java.util.UUID -// TODO: 설문 일정 관련 속성들을 하나의 클래스로 묶기 data class Survey( val id: UUID, val title: String, val description: String, val thumbnail: String?, val publishedAt: Date?, - val finishedAt: Date, val status: SurveyStatus, val finishMessage: String, - val targetParticipantCount: Int, + val rewardSetting: RewardSetting, + /** 해당 설문의 설문이용 노출 여부(false면 메인 페이지 노출 X, 링크를 통해서만 접근 가능) */ + val isVisible: Boolean, val makerId: UUID, - val rewards: List, val sections: List
, ) { init { - require(sections.isNotEmpty()) { throw InvalidSurveyException() } require(isSectionsUnique()) { throw InvalidSurveyException() } require(isSurveyStatusValid()) { throw InvalidSurveyException() } - require(isFinishedAtAfterPublishedAt()) { throw InvalidSurveyException() } - require(isTargetParticipantsEnough()) { throw InvalidSurveyException() } - // TODO: 추후에 리워드가 없는 설문도 생성할 수 있도록 수정하기 - require(rewards.isNotEmpty()) { throw InvalidSurveyException() } + require(isFinishedAtAfterPublishedAt()) { throw InvalidPublishedAtException() } require(isSectionIdsValid()) { throw InvalidSurveyException() } + // 설문이 진행 중인 경우만 섹션이 비었는지, 선택지가 중복되는지 확인 + if (status == SurveyStatus.IN_PROGRESS) { + require(sections.isNotEmpty()) { throw InvalidSurveyException() } + require(isAllChoicesUnique()) { throw InvalidSurveyException() } + } } companion object { - const val DEFAULT_THUMBNAIL_URL = "https://test-oriddle-bucket.s3.ap-northeast-2.amazonaws.com/surveyImage.webp" - const val DEFAULT_TITLE = "제목 없는 설문" + const val DEFAULT_TITLE = "" const val DEFAULT_DESCRIPTION = "" - const val DEFAULT_SURVEY_DURATION = 60L - const val DEFAULT_FINISH_MESSAGE = "설문에 참여해주셔서 감사합니다." - const val DEFAULT_TARGET_PARTICIPANT_COUNT = 100 + const val DEFAULT_FINISH_MESSAGE = "" fun create(makerId: UUID) = Survey( @@ -55,30 +56,19 @@ data class Survey( description = DEFAULT_DESCRIPTION, thumbnail = null, publishedAt = null, - finishedAt = getDefaultFinishedAt(), status = SurveyStatus.NOT_STARTED, finishMessage = DEFAULT_FINISH_MESSAGE, - targetParticipantCount = DEFAULT_TARGET_PARTICIPANT_COUNT, + rewardSetting = NoRewardSetting, + isVisible = true, makerId = makerId, - rewards = listOf(Reward.create()), sections = listOf(Section.create()), ) - - /** 설문 종료일 기본 값은 현재 날짜 기준 60일 뒤, 초 단위 이하 제거 */ - private fun getDefaultFinishedAt() = - Date.from( - LocalDateTime - .now() - .plusDays(DEFAULT_SURVEY_DURATION) - .withSecond(0) - .withNano(0) - .atZone(ZoneId.systemDefault()) - .toInstant(), - ) } /** 설문의 응답 순서가 유효한지, 응답이 각 섹션에 유효한지 확인하는 메서드 */ fun validateResponse(surveyResponse: SurveyResponse) { + // 진행 중인 설문이 아니면 응답이 유효한지 확인할 수 없다. + require(status == SurveyStatus.IN_PROGRESS) { throw SurveyClosedException() } // 확인할 응답의 예상 섹션 ID, 첫 응답의 섹션 ID는 첫 섹션의 ID var expectedSectionId: SectionId = sections.first().id for (sectionResponse in surveyResponse) { @@ -98,17 +88,16 @@ data class Survey( title: String, description: String, thumbnail: String?, - finishedAt: Date, finishMessage: String, - targetParticipantCount: Int, - rewards: List, + rewardSetting: RewardSetting, + isVisible: Boolean, sections: List
, ): Survey { // 설문이 시작 전 상태이거나, 수정 중이면서 리워드 관련 정보가 변경되지 않아야한다. require( status == SurveyStatus.NOT_STARTED || status == SurveyStatus.IN_MODIFICATION && - isRewardInfoEquals(targetParticipantCount, rewards), + rewardSetting == this.rewardSetting, ) { throw InvalidUpdateSurveyException() } @@ -116,39 +105,59 @@ data class Survey( title = title, description = description, thumbnail = thumbnail, - finishedAt = finishedAt, finishMessage = finishMessage, - targetParticipantCount = targetParticipantCount, - rewards = rewards, + rewardSetting = rewardSetting, + isVisible = isVisible, sections = sections, ) } - /** 리워드 관련 정보가 같은지 확인하는 메서드 */ - private fun isRewardInfoEquals( - targetParticipantCount: Int, - rewards: List, - ) = targetParticipantCount == this.targetParticipantCount && rewards == this.rewards - fun finish() = copy(status = SurveyStatus.CLOSED) - fun start(): Survey { - require(status == SurveyStatus.NOT_STARTED) { throw InvalidSurveyStartException() } - return copy(status = SurveyStatus.IN_PROGRESS, publishedAt = DateUtil.getCurrentDate()) + /** 설문을 IN_PROGRESS 상태로 변경하는 메서드. 설문이 시작 전이거나 수정 중인 경우만 가능하다. */ + fun start() = + when (status) { + SurveyStatus.NOT_STARTED -> + copy( + status = SurveyStatus.IN_PROGRESS, + publishedAt = DateUtil.getCurrentDate(), + rewardSetting = + RewardSetting.of( + type = rewardSetting.type, + rewards = rewardSetting.rewards, + targetParticipantCount = rewardSetting.targetParticipantCount, + finishedAt = rewardSetting.finishedAt?.value, + surveyStatus = SurveyStatus.IN_PROGRESS, + ), + ) + SurveyStatus.IN_MODIFICATION -> copy(status = SurveyStatus.IN_PROGRESS) + SurveyStatus.IN_PROGRESS -> throw InvalidSurveyStartException() + SurveyStatus.CLOSED -> throw InvalidSurveyStartException() + } + + fun edit(): Survey { + require(status == SurveyStatus.IN_PROGRESS) { throw InvalidSurveyEditException() } + return copy(status = SurveyStatus.IN_MODIFICATION) } - fun getRewardCount() = rewards.sumOf { it.count } + fun isImmediateDraw() = rewardSetting.isImmediateDraw private fun isSectionsUnique() = sections.size == sections.distinctBy { it.id }.size private fun isSurveyStatusValid() = publishedAt != null || status == SurveyStatus.NOT_STARTED - private fun isFinishedAtAfterPublishedAt() = publishedAt == null || finishedAt.after(publishedAt) - - private fun isTargetParticipantsEnough() = targetParticipantCount >= getRewardCount() + private fun isFinishedAtAfterPublishedAt(): Boolean { + if (publishedAt == null) return true + if (rewardSetting is SelfManagementSetting) return rewardSetting.finishedAt.value.after(publishedAt) + if (rewardSetting is ImmediateDrawSetting) return rewardSetting.finishedAt.value.after(publishedAt) + return true + } private fun isSectionIdsValid(): Boolean { + if (sections.isEmpty()) return true val sectionIds = SectionIds.from(sections.map { it.id }) return sections.all { it.sectionIds == sectionIds } } + + private fun isAllChoicesUnique() = sections.all { section -> section.questions.all { it.choices?.isUnique() ?: true } } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/question/choice/Choices.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/question/choice/Choices.kt index bef8b5bd..dbc3a5c3 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/question/choice/Choices.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/question/choice/Choices.kt @@ -9,9 +9,13 @@ data class Choices( /** 기타 선택지 허용 여부 */ val isAllowOther: Boolean, ) { + companion object { + const val MAX_SIZE = 20 + } + init { if (standardChoices.isEmpty()) throw InvalidChoiceException() - if (standardChoices.size != standardChoices.distinct().size) throw InvalidChoiceException() + if (standardChoices.size > MAX_SIZE) throw InvalidChoiceException() } /** 응답이 선택지에 포함되는지 확인 */ @@ -22,4 +26,6 @@ data class Choices( /** 선택지 기반 라우팅의 선택지와 같은지 비교하기 위해 선택지 집합을 얻는다. */ fun getChoiceSet() = if (isAllowOther) standardChoices.toSet() + Choice.Other else standardChoices.toSet() + + fun isUnique() = standardChoices.size == standardChoices.distinct().size } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/QuestionFilter.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/QuestionFilter.kt new file mode 100644 index 00000000..7a96452a --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/QuestionFilter.kt @@ -0,0 +1,14 @@ +package com.sbl.sulmun2yong.survey.domain.result + +import com.sbl.sulmun2yong.survey.exception.InvalidQuestionFilterException +import java.util.UUID + +data class QuestionFilter( + val questionId: UUID, + val contents: List, + val isPositive: Boolean, +) { + init { + require(contents.isNotEmpty()) { throw InvalidQuestionFilterException() } + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/QuestionResult.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/QuestionResult.kt new file mode 100644 index 00000000..77436e7d --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/QuestionResult.kt @@ -0,0 +1,14 @@ +package com.sbl.sulmun2yong.survey.domain.result + +import java.util.SortedSet +import java.util.UUID + +data class QuestionResult( + val questionId: UUID, + val resultDetails: List, + /** 해당 질문의 모든 응답 집합 */ + val contents: SortedSet, +) { + fun getMatchedParticipants(questionFilter: QuestionFilter): Set = + resultDetails.mapNotNull { if (it.isMatched(questionFilter)) it.participantId else null }.toSet() +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultDetails.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultDetails.kt new file mode 100644 index 00000000..0f2bbddf --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultDetails.kt @@ -0,0 +1,15 @@ +package com.sbl.sulmun2yong.survey.domain.result + +import com.sbl.sulmun2yong.survey.exception.InvalidResultDetailsException +import java.util.UUID + +data class ResultDetails( + val participantId: UUID, + val contents: List, +) { + init { + require(contents.isNotEmpty()) { throw InvalidResultDetailsException() } + } + + fun isMatched(questionFilter: QuestionFilter) = contents.any { questionFilter.contents.contains(it) } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultFilter.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultFilter.kt new file mode 100644 index 00000000..d1cbc5cd --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultFilter.kt @@ -0,0 +1,15 @@ +package com.sbl.sulmun2yong.survey.domain.result + +import com.sbl.sulmun2yong.survey.exception.InvalidResultFilterException + +data class ResultFilter( + val questionFilters: List, +) { + companion object { + const val MAX_SIZE = 20 + } + + init { + require(questionFilters.size <= MAX_SIZE) { throw InvalidResultFilterException() } + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/SurveyResult.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/SurveyResult.kt new file mode 100644 index 00000000..b3ebb045 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/result/SurveyResult.kt @@ -0,0 +1,49 @@ +package com.sbl.sulmun2yong.survey.domain.result + +import java.util.UUID + +data class SurveyResult( + val questionResults: List, +) { + fun getFilteredResult(resultFilter: ResultFilter): SurveyResult { + val questionFilters = resultFilter.questionFilters + var filteredSurveyResult = copy() + for (questionFilter in questionFilters) { + val targetQuestionResult = findQuestionResult(questionFilter.questionId) ?: continue + val participantSet = targetQuestionResult.getMatchedParticipants(questionFilter) + filteredSurveyResult = filteredSurveyResult.filterByQuestionFilter(participantSet, questionFilter) + } + return filteredSurveyResult + } + + fun findQuestionResult(questionId: UUID) = questionResults.find { it.questionId == questionId } + + fun getParticipantCount() = + questionResults + .map { it.resultDetails.map { resultDetail -> resultDetail.participantId } } + .flatten() + .toSet() + .size + + private fun filterByQuestionFilter( + participantSet: Set, + questionFilter: QuestionFilter, + ): SurveyResult = + SurveyResult( + questionResults.map { questionResult -> + val responseDetails = + if (questionFilter.isPositive) { + questionResult.resultDetails.filter { + participantSet.contains(it.participantId) + } + } else { + questionResult.resultDetails.filter { !participantSet.contains(it.participantId) } + } + QuestionResult( + questionResult.questionId, + responseDetails, + questionResult.contents, + ) + }, + ) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/FinishedAt.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/FinishedAt.kt new file mode 100644 index 00000000..d4a1ed51 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/FinishedAt.kt @@ -0,0 +1,20 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +import com.sbl.sulmun2yong.survey.exception.InvalidFinishedAtException +import java.util.Calendar +import java.util.Date + +/** 설문 종료일, 1시간 단위(분 단위 이하 0) */ +data class FinishedAt( + val value: Date, +) { + init { + require(isMinuteAndBelowZero()) { throw InvalidFinishedAtException() } + } + + private fun isMinuteAndBelowZero(): Boolean { + val calendar = Calendar.getInstance() + calendar.time = value + return calendar.get(Calendar.MINUTE) == 0 && calendar.get(Calendar.SECOND) == 0 && calendar.get(Calendar.MILLISECOND) == 0 + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/ImmediateDrawSetting.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/ImmediateDrawSetting.kt new file mode 100644 index 00000000..80aec321 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/ImmediateDrawSetting.kt @@ -0,0 +1,20 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +import com.sbl.sulmun2yong.survey.exception.InvalidRewardSettingException + +/** 즉시 추첨(설문 참여 후 추첨 보드를 통해 즉시 추첨 진행) */ +data class ImmediateDrawSetting( + override val rewards: List, + override val targetParticipantCount: Int, + override val finishedAt: FinishedAt, +) : RewardSetting { + override val type = RewardSettingType.IMMEDIATE_DRAW + + init { + require(rewards.isNotEmpty()) { throw InvalidRewardSettingException() } + // 즉시 추첨은 리워드 개수의 총합이 목표 참여자 수보다 적어야함 + require(isTargetParticipantValid()) { throw InvalidRewardSettingException() } + } + + private fun isTargetParticipantValid() = targetParticipantCount >= getRewardCount() +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/NoRewardSetting.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/NoRewardSetting.kt new file mode 100644 index 00000000..d9b2df93 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/NoRewardSetting.kt @@ -0,0 +1,9 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +/** 직접 관리(사용자가 직접 리워드 지급) */ +object NoRewardSetting : RewardSetting { + override val rewards = emptyList() + override val finishedAt = null + override val type = RewardSettingType.NO_REWARD + override val targetParticipantCount = null +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/NotStartedDrawSetting.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/NotStartedDrawSetting.kt new file mode 100644 index 00000000..6989b5a1 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/NotStartedDrawSetting.kt @@ -0,0 +1,9 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +/** 시작 전 상태의 불완전한 리워드 지급 설정 */ +data class NotStartedDrawSetting( + override val type: RewardSettingType, + override val rewards: List, + override val targetParticipantCount: Int?, + override val finishedAt: FinishedAt?, +) : RewardSetting diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Reward.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/Reward.kt similarity index 92% rename from src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Reward.kt rename to src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/Reward.kt index 3793987f..6081e2d8 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/Reward.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/Reward.kt @@ -1,4 +1,4 @@ -package com.sbl.sulmun2yong.survey.domain +package com.sbl.sulmun2yong.survey.domain.reward import com.sbl.sulmun2yong.survey.exception.InvalidRewardException diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSetting.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSetting.kt new file mode 100644 index 00000000..d294130d --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSetting.kt @@ -0,0 +1,49 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.exception.InvalidRewardSettingException +import java.util.Date + +/** 설문의 리워드 지급에 대한 설정을 담고있는 클래스 */ +interface RewardSetting { + val type: RewardSettingType + val rewards: List + val targetParticipantCount: Int? + val finishedAt: FinishedAt? + val isImmediateDraw: Boolean + get() = type == RewardSettingType.IMMEDIATE_DRAW + + companion object { + fun of( + type: RewardSettingType, + rewards: List, + targetParticipantCount: Int?, + finishedAt: Date?, + surveyStatus: SurveyStatus = SurveyStatus.IN_PROGRESS, + ): RewardSetting { + if (surveyStatus == SurveyStatus.NOT_STARTED) { + val finishedAtValue = finishedAt?.let { FinishedAt(it) } + return NotStartedDrawSetting(type, rewards, targetParticipantCount, finishedAtValue) + } + when (type) { + RewardSettingType.NO_REWARD -> { + val isNoReward = rewards.isEmpty() && targetParticipantCount == null && finishedAt == null + if (isNoReward) return NoRewardSetting + throw InvalidRewardSettingException() + } + RewardSettingType.SELF_MANAGEMENT -> { + val isSelfManagement = rewards.isNotEmpty() && targetParticipantCount == null && finishedAt != null + if (isSelfManagement) return SelfManagementSetting(rewards, FinishedAt(finishedAt!!)) + throw InvalidRewardSettingException() + } + RewardSettingType.IMMEDIATE_DRAW -> { + val isImmediateDraw = rewards.isNotEmpty() && targetParticipantCount != null && finishedAt != null + if (isImmediateDraw) return ImmediateDrawSetting(rewards, targetParticipantCount!!, FinishedAt(finishedAt!!)) + throw InvalidRewardSettingException() + } + } + } + } + + fun getRewardCount() = rewards.sumOf { it.count } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSettingType.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSettingType.kt new file mode 100644 index 00000000..46898ff1 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSettingType.kt @@ -0,0 +1,7 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +enum class RewardSettingType { + IMMEDIATE_DRAW, + SELF_MANAGEMENT, + NO_REWARD, +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/SelfManagementSetting.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/SelfManagementSetting.kt new file mode 100644 index 00000000..10a977a9 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/domain/reward/SelfManagementSetting.kt @@ -0,0 +1,16 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +import com.sbl.sulmun2yong.survey.exception.InvalidRewardSettingException + +/** 직접 관리(사용자가 직접 리워드 지급) */ +data class SelfManagementSetting( + override val rewards: List, + override val finishedAt: FinishedAt, +) : RewardSetting { + override val type = RewardSettingType.SELF_MANAGEMENT + override val targetParticipantCount = null + + init { + require(rewards.isNotEmpty()) { throw InvalidRewardSettingException() } + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/MySurveySortType.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/MySurveySortType.kt new file mode 100644 index 00000000..d28321d4 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/MySurveySortType.kt @@ -0,0 +1,8 @@ +package com.sbl.sulmun2yong.survey.dto.request + +enum class MySurveySortType { + LAST_MODIFIED, + OLD_MODIFIED, + TITLE_ASC, + TITLE_DESC, +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/SurveyResultRequest.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/SurveyResultRequest.kt new file mode 100644 index 00000000..7f726b3a --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/SurveyResultRequest.kt @@ -0,0 +1,27 @@ +package com.sbl.sulmun2yong.survey.dto.request + +import com.sbl.sulmun2yong.survey.domain.result.QuestionFilter +import com.sbl.sulmun2yong.survey.domain.result.ResultFilter +import java.util.UUID + +data class SurveyResultRequest( + val questionFilters: List, +) { + data class QuestionFilterRequest( + val questionId: UUID, + val contents: List, + val isPositive: Boolean, + ) { + fun toDomain() = + QuestionFilter( + questionId = questionId, + contents = contents, + isPositive = isPositive, + ) + } + + fun toDomain() = + ResultFilter( + questionFilters = questionFilters.map { it.toDomain() }, + ) +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/SurveySaveRequest.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/SurveySaveRequest.kt index 44ea69d6..3649d1e6 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/SurveySaveRequest.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/request/SurveySaveRequest.kt @@ -1,11 +1,15 @@ package com.sbl.sulmun2yong.survey.dto.request +import com.sbl.sulmun2yong.survey.domain.SurveyStatus import com.sbl.sulmun2yong.survey.domain.question.QuestionType import com.sbl.sulmun2yong.survey.domain.question.choice.Choice import com.sbl.sulmun2yong.survey.domain.question.choice.Choices import com.sbl.sulmun2yong.survey.domain.question.impl.StandardMultipleChoiceQuestion import com.sbl.sulmun2yong.survey.domain.question.impl.StandardSingleChoiceQuestion import com.sbl.sulmun2yong.survey.domain.question.impl.StandardTextQuestion +import com.sbl.sulmun2yong.survey.domain.reward.Reward +import com.sbl.sulmun2yong.survey.domain.reward.RewardSetting +import com.sbl.sulmun2yong.survey.domain.reward.RewardSettingType import com.sbl.sulmun2yong.survey.domain.routing.RoutingStrategy import com.sbl.sulmun2yong.survey.domain.routing.RoutingType import com.sbl.sulmun2yong.survey.domain.section.Section @@ -19,12 +23,44 @@ data class SurveySaveRequest( val description: String, // TODO: 섬네일의 URL이 우리 서비스의 S3 URL인지 확인하기 val thumbnail: String?, - val finishedAt: Date, val finishMessage: String, - val targetParticipantCount: Int, - val rewards: List, + val isVisible: Boolean, + val rewardSetting: RewardSettingResponse, val sections: List, ) { + fun List.toDomain() = + if (isEmpty()) { + listOf() + } else { + val sectionIds = SectionIds.from(this.map { SectionId.Standard(it.sectionId) }) + this.map { + Section( + id = SectionId.Standard(it.sectionId), + title = it.title, + description = it.description, + routingStrategy = it.getRoutingStrategy(), + questions = it.questions.map { question -> question.toDomain() }, + sectionIds = sectionIds, + ) + } + } + + data class RewardSettingResponse( + val type: RewardSettingType, + val rewards: List, + val targetParticipantCount: Int?, + val finishedAt: Date?, + ) { + fun toDomain(surveyStatus: SurveyStatus) = + RewardSetting.of( + type, + rewards.map { Reward(it.name, it.category, it.count) }, + targetParticipantCount, + finishedAt, + surveyStatus, + ) + } + data class RewardCreateRequest( val name: String, val category: String, @@ -32,23 +68,13 @@ data class SurveySaveRequest( ) data class SectionCreateRequest( - val id: UUID, + val sectionId: UUID, val title: String, val description: String, val questions: List, val routeDetails: RouteDetailsCreateRequest, ) { - fun toDomain(sectionIds: SectionIds) = - Section( - id = SectionId.Standard(id), - title = title, - description = description, - routingStrategy = getRoutingStrategy(), - questions = questions.map { it.toDomain() }, - sectionIds = sectionIds, - ) - - private fun getRoutingStrategy() = + fun getRoutingStrategy() = when (routeDetails.type) { RoutingType.NUMERICAL_ORDER -> RoutingStrategy.NumericalOrder RoutingType.SET_BY_USER -> RoutingStrategy.SetByUser(SectionId.from(routeDetails.nextSectionId)) @@ -76,7 +102,7 @@ data class SurveySaveRequest( ) data class QuestionCreateRequest( - val id: UUID, + val questionId: UUID, val type: QuestionType, val title: String, val description: String, @@ -88,14 +114,14 @@ data class SurveySaveRequest( when (type) { QuestionType.TEXT_RESPONSE -> StandardTextQuestion( - id = id, + id = questionId, title = title, description = description, isRequired = isRequired, ) QuestionType.SINGLE_CHOICE -> StandardSingleChoiceQuestion( - id = id, + id = questionId, title = title, description = description, isRequired = isRequired, @@ -107,7 +133,7 @@ data class SurveySaveRequest( ) QuestionType.MULTIPLE_CHOICE -> StandardMultipleChoiceQuestion( - id = id, + id = questionId, title = title, description = description, isRequired = isRequired, diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/MyPageSurveyInfoResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/MyPageSurveyInfoResponse.kt new file mode 100644 index 00000000..84e81131 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/MyPageSurveyInfoResponse.kt @@ -0,0 +1,15 @@ +package com.sbl.sulmun2yong.survey.dto.response + +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import java.util.Date +import java.util.UUID + +data class MyPageSurveyInfoResponse( + val id: UUID, + val title: String, + val thumbnail: String?, + val updatedAt: Date, + val status: SurveyStatus, + val finishedAt: Date?, + val responseCount: Int, +) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/MyPageSurveysResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/MyPageSurveysResponse.kt new file mode 100644 index 00000000..1e75ea8c --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/MyPageSurveysResponse.kt @@ -0,0 +1,5 @@ +package com.sbl.sulmun2yong.survey.dto.response + +data class MyPageSurveysResponse( + val surveys: List, +) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/ParticipantsInfoListResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/ParticipantsInfoListResponse.kt new file mode 100644 index 00000000..7d5e09c4 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/ParticipantsInfoListResponse.kt @@ -0,0 +1,94 @@ +package com.sbl.sulmun2yong.survey.dto.response + +import com.sbl.sulmun2yong.drawing.domain.DrawingHistory +import com.sbl.sulmun2yong.drawing.domain.DrawingHistoryGroup +import com.sbl.sulmun2yong.drawing.domain.ticket.Ticket +import com.sbl.sulmun2yong.survey.domain.Participant +import java.util.Date +import java.util.UUID + +data class ParticipantsInfoListResponse( + val participants: List, + val targetParticipant: Int?, +) { + data class ParticipantInfoResponse( + val participantId: UUID, + val participatedAt: Date, + val drawInfo: DrawInfoResponse?, + ) + + data class DrawInfoResponse( + val drawResult: DrawResult, + val reward: String?, + val phoneNumber: String?, + ) { + companion object { + fun from(drawingHistory: DrawingHistory?): DrawInfoResponse { + if (drawingHistory == null) { + return DrawInfoResponse( + drawResult = DrawResult.BEFORE_DRAW, + reward = null, + phoneNumber = null, + ) + } + + if (drawingHistory.ticket is Ticket.Winning) { + return DrawInfoResponse( + drawResult = DrawResult.WIN, + reward = drawingHistory.ticket.rewardName, + phoneNumber = drawingHistory.phoneNumber.value, + ) + } + + return DrawInfoResponse( + drawResult = DrawResult.LOSE, + reward = null, + phoneNumber = null, + ) + } + } + } + + enum class DrawResult { + BEFORE_DRAW, + WIN, + LOSE, + } + + companion object { + fun of( + participants: List, + drawingHistories: DrawingHistoryGroup?, + targetParticipant: Int?, + ): ParticipantsInfoListResponse { + if (drawingHistories == null) { + return ParticipantsInfoListResponse( + participants = + participants.map { + ParticipantInfoResponse( + participantId = it.id, + participatedAt = it.createdAt, + drawInfo = null, + ) + }, + targetParticipant = targetParticipant, + ) + } + + val drawingHistoryMap = drawingHistories.histories.associateBy { it.participantId } + + return ParticipantsInfoListResponse( + participants = + participants.map { + val drawingHistory = drawingHistoryMap[it.id] + ParticipantInfoResponse( + participantId = it.id, + participatedAt = it.createdAt, + drawInfo = DrawInfoResponse.from(drawingHistory), + ) + }, + targetParticipant = targetParticipant, + ) + } + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyInfoResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyInfoResponse.kt index d9e15fac..74a85af4 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyInfoResponse.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyInfoResponse.kt @@ -1,34 +1,36 @@ package com.sbl.sulmun2yong.survey.dto.response -import com.sbl.sulmun2yong.survey.domain.Reward import com.sbl.sulmun2yong.survey.domain.Survey import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.domain.reward.Reward +import com.sbl.sulmun2yong.survey.domain.reward.RewardSettingType import java.util.Date -// TODO: 설문 제작자 정보도 추가하기 data class SurveyInfoResponse( val title: String, val description: String, val status: SurveyStatus, - val finishedAt: Date, - val currentParticipants: Int, - val targetParticipants: Int, - val thumbnail: String, + val type: RewardSettingType, + val finishedAt: Date?, + val currentParticipants: Int?, + val targetParticipants: Int?, val rewards: List, + val thumbnail: String?, ) { companion object { fun of( survey: Survey, - currentParticipants: Int, + currentParticipants: Int?, ) = SurveyInfoResponse( title = survey.title, description = survey.description, status = survey.status, - finishedAt = survey.finishedAt, + type = survey.rewardSetting.type, + finishedAt = survey.rewardSetting.finishedAt?.value, currentParticipants = currentParticipants, - targetParticipants = survey.targetParticipantCount, - thumbnail = survey.thumbnail ?: Survey.DEFAULT_THUMBNAIL_URL, - rewards = survey.rewards.map { it.toResponse() }, + targetParticipants = survey.rewardSetting.targetParticipantCount, + rewards = survey.rewardSetting.rewards.map { it.toResponse() }, + thumbnail = survey.thumbnail, ) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyListResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyListResponse.kt index 2ef3bf99..92e053d1 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyListResponse.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyListResponse.kt @@ -1,7 +1,8 @@ package com.sbl.sulmun2yong.survey.dto.response -import com.sbl.sulmun2yong.survey.domain.Reward import com.sbl.sulmun2yong.survey.domain.Survey +import com.sbl.sulmun2yong.survey.domain.reward.Reward +import com.sbl.sulmun2yong.survey.domain.reward.RewardSettingType import java.util.Date import java.util.UUID @@ -20,13 +21,14 @@ data class SurveyListResponse( surveys.map { SurveyInfoResponse( surveyId = it.id, - thumbnail = it.thumbnail ?: Survey.DEFAULT_THUMBNAIL_URL, + thumbnail = it.thumbnail, title = it.title, description = it.description, - targetParticipants = it.targetParticipantCount, - finishedAt = it.finishedAt, - rewardCount = it.getRewardCount(), - rewards = it.rewards.toRewardInfoResponses(), + targetParticipants = it.rewardSetting.targetParticipantCount, + finishedAt = it.rewardSetting.finishedAt?.value, + rewardCount = it.rewardSetting.getRewardCount(), + rewardSettingType = it.rewardSetting.type, + rewards = it.rewardSetting.rewards.toRewardInfoResponses(), ) }, ) @@ -34,12 +36,13 @@ data class SurveyListResponse( data class SurveyInfoResponse( val surveyId: UUID, - val thumbnail: String, + val thumbnail: String?, val title: String, val description: String, - val targetParticipants: Int, + val targetParticipants: Int?, val rewardCount: Int, - val finishedAt: Date, + val finishedAt: Date?, + val rewardSettingType: RewardSettingType, val rewards: List, ) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyMakeInfoResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyMakeInfoResponse.kt index 04931859..bd25d43b 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyMakeInfoResponse.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyMakeInfoResponse.kt @@ -4,6 +4,7 @@ import com.sbl.sulmun2yong.survey.domain.Survey import com.sbl.sulmun2yong.survey.domain.SurveyStatus import com.sbl.sulmun2yong.survey.domain.question.Question import com.sbl.sulmun2yong.survey.domain.question.QuestionType +import com.sbl.sulmun2yong.survey.domain.reward.RewardSettingType import com.sbl.sulmun2yong.survey.domain.routing.RoutingStrategy import com.sbl.sulmun2yong.survey.domain.routing.RoutingType import com.sbl.sulmun2yong.survey.domain.section.Section @@ -15,11 +16,10 @@ data class SurveyMakeInfoResponse( val description: String, val thumbnail: String?, val publishedAt: Date?, - val finishedAt: Date, + val rewardSetting: RewardSettingResponse, val status: SurveyStatus, val finishMessage: String, - val targetParticipantCount: Int, - val rewards: List, + val isVisible: Boolean, val sections: List, ) { companion object { @@ -29,15 +29,27 @@ data class SurveyMakeInfoResponse( description = survey.description, thumbnail = survey.thumbnail, publishedAt = survey.publishedAt, - finishedAt = survey.finishedAt, + rewardSetting = + RewardSettingResponse( + type = survey.rewardSetting.type, + rewards = survey.rewardSetting.rewards.map { RewardMakeInfoResponse(it.name, it.category, it.count) }, + targetParticipantCount = survey.rewardSetting.targetParticipantCount, + finishedAt = survey.rewardSetting.finishedAt?.value, + ), status = survey.status, finishMessage = survey.finishMessage, - targetParticipantCount = survey.targetParticipantCount, - rewards = survey.rewards.map { RewardMakeInfoResponse(it.name, it.category, it.count) }, + isVisible = survey.isVisible, sections = survey.sections.map { SectionMakeInfoResponse.from(it) }, ) } + data class RewardSettingResponse( + val type: RewardSettingType, + val rewards: List, + val targetParticipantCount: Int?, + val finishedAt: Date?, + ) + data class RewardMakeInfoResponse( val name: String, val category: String, @@ -45,7 +57,7 @@ data class SurveyMakeInfoResponse( ) data class SectionMakeInfoResponse( - val id: UUID, + val sectionId: UUID, val title: String, val description: String, val questions: List, @@ -54,7 +66,7 @@ data class SurveyMakeInfoResponse( companion object { fun from(section: Section) = SectionMakeInfoResponse( - id = section.id.value, + sectionId = section.id.value, title = section.title, description = section.description, questions = section.questions.map { QuestionMakeInfoResponse.from(it) }, @@ -96,7 +108,7 @@ data class SurveyMakeInfoResponse( ) data class QuestionMakeInfoResponse( - val id: UUID, + val questionId: UUID, val type: QuestionType, val title: String, val description: String, @@ -107,7 +119,7 @@ data class SurveyMakeInfoResponse( companion object { fun from(question: Question) = QuestionMakeInfoResponse( - id = question.id, + questionId = question.id, type = question.questionType, title = question.title, description = question.description, diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyParticipantResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyParticipantResponse.kt index 67dc3df0..6d9e1c3e 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyParticipantResponse.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyParticipantResponse.kt @@ -4,4 +4,6 @@ import java.util.UUID data class SurveyParticipantResponse( val participantId: UUID, + /** 즉시 추첨 방식인 경우 True -> False면 추첨 페이지 스킵 */ + val isImmediateDraw: Boolean, ) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyResultResponse.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyResultResponse.kt new file mode 100644 index 00000000..20fb2ed4 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/dto/response/SurveyResultResponse.kt @@ -0,0 +1,101 @@ +package com.sbl.sulmun2yong.survey.dto.response + +import com.sbl.sulmun2yong.survey.domain.Survey +import com.sbl.sulmun2yong.survey.domain.question.ChoiceQuestion +import com.sbl.sulmun2yong.survey.domain.question.Question +import com.sbl.sulmun2yong.survey.domain.question.QuestionType +import com.sbl.sulmun2yong.survey.domain.result.QuestionResult +import com.sbl.sulmun2yong.survey.domain.result.SurveyResult +import com.sbl.sulmun2yong.survey.domain.section.Section +import java.util.UUID + +data class SurveyResultResponse( + val sectionResults: List, + val participantCount: Int, +) { + companion object { + fun of( + surveyResult: SurveyResult, + survey: Survey, + participantCount: Int, + ) = SurveyResultResponse(survey.sections.map { SectionResultResponse.of(surveyResult, it) }, participantCount) + } + + data class SectionResultResponse( + val sectionId: UUID, + val title: String, + val questionResults: List, + ) { + companion object { + fun of( + surveyResult: SurveyResult, + section: Section, + ): SectionResultResponse = + SectionResultResponse( + sectionId = section.id.value, + title = section.title, + questionResults = section.questions.map { QuestionResultResponse.of(it, surveyResult.findQuestionResult(it.id)) }, + ) + } + } + + data class QuestionResultResponse( + val questionId: UUID, + val title: String, + val type: QuestionType, + val participantCount: Int, + val responses: List, + val responseContents: List, + ) { + companion object { + fun of( + question: Question, + questionResult: QuestionResult?, + ): QuestionResultResponse { + if (questionResult == null) { + return QuestionResultResponse( + questionId = question.id, + title = question.title, + type = question.questionType, + participantCount = 0, + responses = listOf(), + responseContents = question.choices?.standardChoices?.map { it.content } ?: listOf(), + ) + } + + val allContents = + if (question is ChoiceQuestion) { + val tempContents = question.choices.standardChoices.map { it.content } + tempContents + questionResult.contents.filter { !tempContents.contains(it) } + } else { + questionResult.contents.toList() + } + + val responses = + if (question is ChoiceQuestion) { + val contentCountMap = + questionResult.resultDetails + .flatMap { it.contents } + .groupingBy { it } + .eachCount() + allContents.map { Response(it, contentCountMap[it] ?: 0) } + } else { + questionResult.resultDetails.map { Response(it.contents.first(), 1) } + } + return QuestionResultResponse( + questionId = question.id, + title = question.title, + type = question.questionType, + participantCount = questionResult.resultDetails.size, + responses = responses, + responseContents = allContents, + ) + } + } + + data class Response( + val content: String, + val count: Int, + ) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ParticipantDocument.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ParticipantDocument.kt index 40f81a53..960da0e7 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ParticipantDocument.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ParticipantDocument.kt @@ -18,9 +18,9 @@ data class ParticipantDocument( fun of(participant: Participant) = ParticipantDocument( id = participant.id, + visitorId = participant.visitorId, surveyId = participant.surveyId, userId = participant.userId, - visitorId = participant.visitorId, ) } @@ -30,5 +30,6 @@ data class ParticipantDocument( visitorId = this.visitorId, surveyId = this.surveyId, userId = this.userId, + createdAt = this.createdAt, ) } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ResponseDocument.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ResponseDocument.kt index a82bab13..069dc0e7 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ResponseDocument.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/ResponseDocument.kt @@ -10,6 +10,7 @@ data class ResponseDocument( @Id val id: UUID, val participantId: UUID, + val surveyId: UUID, val questionId: UUID, val content: String, ) : BaseTimeDocument() diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/SurveyDocument.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/SurveyDocument.kt index 605aa4a7..89f94745 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/SurveyDocument.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/entity/SurveyDocument.kt @@ -1,7 +1,6 @@ package com.sbl.sulmun2yong.survey.entity import com.sbl.sulmun2yong.global.entity.BaseTimeDocument -import com.sbl.sulmun2yong.survey.domain.Reward import com.sbl.sulmun2yong.survey.domain.Survey import com.sbl.sulmun2yong.survey.domain.SurveyStatus import com.sbl.sulmun2yong.survey.domain.question.Question @@ -11,6 +10,9 @@ import com.sbl.sulmun2yong.survey.domain.question.choice.Choices import com.sbl.sulmun2yong.survey.domain.question.impl.StandardMultipleChoiceQuestion import com.sbl.sulmun2yong.survey.domain.question.impl.StandardSingleChoiceQuestion import com.sbl.sulmun2yong.survey.domain.question.impl.StandardTextQuestion +import com.sbl.sulmun2yong.survey.domain.reward.Reward +import com.sbl.sulmun2yong.survey.domain.reward.RewardSetting +import com.sbl.sulmun2yong.survey.domain.reward.RewardSettingType import com.sbl.sulmun2yong.survey.domain.routing.RoutingStrategy import com.sbl.sulmun2yong.survey.domain.routing.RoutingType import com.sbl.sulmun2yong.survey.domain.section.Section @@ -29,13 +31,16 @@ data class SurveyDocument( val description: String, val thumbnail: String?, val publishedAt: Date?, - val finishedAt: Date, + val finishedAt: Date?, val status: SurveyStatus, val finishMessage: String, - val targetParticipantCount: Int, + val targetParticipantCount: Int?, + val rewardSettingType: RewardSettingType, + val isVisible: Boolean, val makerId: UUID, val rewards: List, val sections: List, + val isDeleted: Boolean = false, ) : BaseTimeDocument() { companion object { fun from(survey: Survey) = @@ -45,12 +50,14 @@ data class SurveyDocument( description = survey.description, thumbnail = survey.thumbnail, publishedAt = survey.publishedAt, - finishedAt = survey.finishedAt, + finishedAt = survey.rewardSetting.finishedAt?.value, status = survey.status, finishMessage = survey.finishMessage, - targetParticipantCount = survey.targetParticipantCount, + targetParticipantCount = survey.rewardSetting.targetParticipantCount, makerId = survey.makerId, - rewards = survey.rewards.map { it.toDocument() }, + rewards = survey.rewardSetting.rewards.map { it.toDocument() }, + rewardSettingType = survey.rewardSetting.type, + isVisible = survey.isVisible, sections = survey.sections.map { it.toDocument() }, ) @@ -145,23 +152,35 @@ data class SurveyDocument( val isAllowOther: Boolean, ) - fun toDomain(): Survey { - val sectionIds = SectionIds.from(this.sections.map { SectionId.Standard(it.sectionId) }) - return Survey( + fun toDomain(): Survey = + Survey( id = this.id, title = this.title, description = this.description, thumbnail = this.thumbnail, - finishedAt = this.finishedAt, publishedAt = this.publishedAt, status = this.status, finishMessage = this.finishMessage, - targetParticipantCount = this.targetParticipantCount, + rewardSetting = + RewardSetting.of( + this.rewardSettingType, + this.rewards.map { it.toDomain() }, + this.targetParticipantCount, + this.finishedAt, + this.status, + ), + isVisible = this.isVisible, makerId = this.makerId, - rewards = this.rewards.map { it.toDomain() }, - sections = this.sections.map { it.toDomain(sectionIds) }, + sections = this.sections.toDomain(), ) - } + + private fun List.toDomain() = + if (this.isEmpty()) { + listOf() + } else { + val sectionIds = SectionIds.from(this.map { SectionId.Standard(it.sectionId) }) + this.map { it.toDomain(sectionIds) } + } private fun RewardSubDocument.toDomain() = Reward( diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidFinishedAtException.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidFinishedAtException.kt new file mode 100644 index 00000000..d21401fd --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidFinishedAtException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.survey.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidFinishedAtException : BusinessException(ErrorCode.INVALID_FINISHED_AT) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidPublishedAtException.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidPublishedAtException.kt new file mode 100644 index 00000000..331e89c7 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidPublishedAtException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.survey.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidPublishedAtException : BusinessException(ErrorCode.INVALID_PUBLISHED_AT) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidQuestionFilterException.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidQuestionFilterException.kt new file mode 100644 index 00000000..e5e7e4c4 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidQuestionFilterException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.survey.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidQuestionFilterException : BusinessException(ErrorCode.INVALID_QUESTION_FILTER) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidResultDetailsException.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidResultDetailsException.kt new file mode 100644 index 00000000..64a8fcd5 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidResultDetailsException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.survey.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidResultDetailsException : BusinessException(ErrorCode.INVALID_RESULT_DETAILS) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidResultFilterException.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidResultFilterException.kt new file mode 100644 index 00000000..cbb6c3b8 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidResultFilterException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.survey.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidResultFilterException : BusinessException(ErrorCode.INVALID_RESULT_FILTER) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidRewardSettingException.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidRewardSettingException.kt new file mode 100644 index 00000000..34b02bf3 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/exception/InvalidRewardSettingException.kt @@ -0,0 +1,6 @@ +package com.sbl.sulmun2yong.survey.exception + +import com.sbl.sulmun2yong.global.error.BusinessException +import com.sbl.sulmun2yong.global.error.ErrorCode + +class InvalidRewardSettingException : BusinessException(ErrorCode.INVALID_REWARD_SETTING) diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ParticipantRepository.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ParticipantRepository.kt index b99dfd37..39fd1163 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ParticipantRepository.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ParticipantRepository.kt @@ -8,6 +8,8 @@ import java.util.UUID @Repository interface ParticipantRepository : MongoRepository { + fun findBySurveyId(surveyId: UUID): List + fun findBySurveyIdAndVisitorId( surveyId: UUID, visitorId: String, diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ResponseRepository.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ResponseRepository.kt index 0b8a9360..5480bbc8 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ResponseRepository.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/ResponseRepository.kt @@ -6,4 +6,11 @@ import org.springframework.stereotype.Repository import java.util.UUID @Repository -interface ResponseRepository : MongoRepository +interface ResponseRepository : MongoRepository { + fun findBySurveyId(surveyId: UUID): List + + fun findBySurveyIdAndParticipantId( + surveyId: UUID, + participantId: UUID, + ): List +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyCustomRepository.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyCustomRepository.kt new file mode 100644 index 00000000..19d43723 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyCustomRepository.kt @@ -0,0 +1,19 @@ +package com.sbl.sulmun2yong.survey.repository + +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.dto.request.MySurveySortType +import com.sbl.sulmun2yong.survey.dto.response.MyPageSurveyInfoResponse +import java.util.UUID + +interface SurveyCustomRepository { + fun findSurveysWithResponseCount( + makerId: UUID, + status: SurveyStatus?, + sortType: MySurveySortType, + ): List + + fun softDelete( + surveyId: UUID, + makerId: UUID, + ): Boolean +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyCustomRepositoryImpl.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyCustomRepositoryImpl.kt new file mode 100644 index 00000000..98cbf2d4 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyCustomRepositoryImpl.kt @@ -0,0 +1,73 @@ +package com.sbl.sulmun2yong.survey.repository + +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.dto.request.MySurveySortType +import com.sbl.sulmun2yong.survey.dto.response.MyPageSurveyInfoResponse +import com.sbl.sulmun2yong.survey.entity.SurveyDocument +import org.springframework.data.domain.Sort +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.aggregation.Aggregation +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update +import java.util.UUID + +class SurveyCustomRepositoryImpl( + private val mongoTemplate: MongoTemplate, +) : SurveyCustomRepository { + override fun findSurveysWithResponseCount( + makerId: UUID, + status: SurveyStatus?, + sortType: MySurveySortType, + ): List { + val matchStage = + Aggregation.match( + Criteria.where("makerId").`is`(makerId).and("isDeleted").`is`(false).apply { + status?.let { and("status").`is`(it) } + }, + ) + + val lookupStage = Aggregation.lookup("participants", "_id", "surveyId", "participantDocs") + + val projectStage = + Aggregation + .project("_id", "title", "thumbnail", "updatedAt", "status", "finishedAt") + .andExpression("size(participantDocs)") + .`as`("responseCount") + + val sortStage = Aggregation.sort(sortType.toSort()) + + val aggregation = Aggregation.newAggregation(matchStage, lookupStage, projectStage, sortStage) + + val results = mongoTemplate.aggregate(aggregation, "surveys", MyPageSurveyInfoResponse::class.java) + return results.mappedResults + } + + override fun softDelete( + surveyId: UUID, + makerId: UUID, + ): Boolean { + val query = + Query( + Criteria + .where("_id") + .`is`(surveyId) + .and("makerId") + .`is`(makerId), + ) + val update = Update().set("isDeleted", true) + + val result = mongoTemplate.updateFirst(query, update, SurveyDocument::class.java) + + // 변경된 문서가 있을 경우 true, 없을 경우 false 반환 + return result.modifiedCount > 0 + } + + private fun MySurveySortType.toSort() = + when (this) { + MySurveySortType.LAST_MODIFIED -> Sort.by(Sort.Direction.DESC, "updatedAt") + MySurveySortType.OLD_MODIFIED -> Sort.by(Sort.Direction.ASC, "updatedAt") + MySurveySortType.TITLE_ASC -> Sort.by(Sort.Direction.ASC, "title") + MySurveySortType.TITLE_DESC -> Sort.by(Sort.Direction.DESC, "title") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyRepository.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyRepository.kt index 319e8394..c23ebc84 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyRepository.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/repository/SurveyRepository.kt @@ -5,13 +5,28 @@ import com.sbl.sulmun2yong.survey.entity.SurveyDocument import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.mongodb.repository.MongoRepository +import org.springframework.data.mongodb.repository.Query import org.springframework.stereotype.Repository +import java.util.Date +import java.util.Optional import java.util.UUID @Repository -interface SurveyRepository : MongoRepository { - fun findByStatus( +interface SurveyRepository : + MongoRepository, + SurveyCustomRepository { + fun findByStatusAndIsVisibleTrueAndIsDeletedFalse( status: SurveyStatus, pageable: Pageable, ): Page + + fun findByIdAndMakerIdAndIsDeletedFalse( + id: UUID, + makerId: UUID, + ): Optional + + fun findByIdAndIsDeletedFalse(id: UUID): Optional + + @Query("{ 'finishedAt': { \$lt: ?0 }, 'status': { \$in: ['IN_PROGRESS', 'IN_MODIFICATION'] }, 'isDeleted': false }") + fun findFinishTargets(now: Date): List } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/scheduler/SurveyFinishScheduler.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/scheduler/SurveyFinishScheduler.kt new file mode 100644 index 00000000..6e643c82 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/scheduler/SurveyFinishScheduler.kt @@ -0,0 +1,29 @@ +package com.sbl.sulmun2yong.survey.scheduler + +import com.sbl.sulmun2yong.global.error.GlobalExceptionHandler +import com.sbl.sulmun2yong.survey.adapter.SurveyAdapter +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.util.Date + +@Service +class SurveyFinishScheduler( + private val surveyAdapter: SurveyAdapter, +) { + private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + @Scheduled(cron = "0 0 * * * *") // 매 시 정각에 실행 + fun closeExpiredSurveys() { + val targetSurveys = surveyAdapter.findFinishTargets(Date()) + val finishedTargetSurveys = + targetSurveys.map { + log.info("설문 종료 시도: ${it.id}") + it.finish() + } + + surveyAdapter.saveAll(finishedTargetSurveys) + + log.info("${targetSurveys.size}개의 설문을 성공적으로 종료했습니다.") + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyInfoService.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyInfoService.kt index 44efb3c1..d2d6f72c 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyInfoService.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyInfoService.kt @@ -3,7 +3,9 @@ package com.sbl.sulmun2yong.survey.service import com.sbl.sulmun2yong.drawing.adapter.DrawingBoardAdapter import com.sbl.sulmun2yong.survey.adapter.SurveyAdapter import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.dto.request.MySurveySortType import com.sbl.sulmun2yong.survey.dto.request.SurveySortType +import com.sbl.sulmun2yong.survey.dto.response.MyPageSurveysResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyInfoResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyListResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyProgressInfoResponse @@ -35,8 +37,9 @@ class SurveyInfoService( fun getSurveyInfo(surveyId: UUID): SurveyInfoResponse { val survey = surveyAdapter.getSurvey(surveyId) if (survey.status == SurveyStatus.NOT_STARTED) throw InvalidSurveyAccessException() - val drawingBoard = drawingBoardAdapter.getBySurveyId(surveyId) - return SurveyInfoResponse.of(survey, drawingBoard.selectedTicketCount) + val selectedTicketCount = + if (survey.rewardSetting.isImmediateDraw) drawingBoardAdapter.getBySurveyId(surveyId).selectedTicketCount else null + return SurveyInfoResponse.of(survey, selectedTicketCount) } fun getSurveyProgressInfo(surveyId: UUID): SurveyProgressInfoResponse? { @@ -44,4 +47,13 @@ class SurveyInfoService( if (survey.status != SurveyStatus.IN_PROGRESS) throw InvalidSurveyAccessException() return SurveyProgressInfoResponse.of(survey) } + + fun getMyPageSurveys( + makerId: UUID, + status: SurveyStatus?, + sortType: MySurveySortType, + ): MyPageSurveysResponse { + val myPageSurveysInfoResponse = surveyAdapter.getMyPageSurveysInfo(makerId, status, sortType) + return MyPageSurveysResponse(myPageSurveysInfoResponse) + } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyManagementService.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyManagementService.kt index 9fc6a876..ecf905e1 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyManagementService.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyManagementService.kt @@ -3,14 +3,12 @@ package com.sbl.sulmun2yong.survey.service import com.sbl.sulmun2yong.drawing.adapter.DrawingBoardAdapter import com.sbl.sulmun2yong.drawing.domain.DrawingBoard import com.sbl.sulmun2yong.survey.adapter.SurveyAdapter -import com.sbl.sulmun2yong.survey.domain.Reward import com.sbl.sulmun2yong.survey.domain.Survey -import com.sbl.sulmun2yong.survey.domain.section.SectionId -import com.sbl.sulmun2yong.survey.domain.section.SectionIds +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.domain.reward.ImmediateDrawSetting import com.sbl.sulmun2yong.survey.dto.request.SurveySaveRequest import com.sbl.sulmun2yong.survey.dto.response.SurveyCreateResponse import com.sbl.sulmun2yong.survey.dto.response.SurveyMakeInfoResponse -import com.sbl.sulmun2yong.survey.exception.InvalidSurveyAccessException import org.springframework.stereotype.Service import java.util.UUID @@ -31,22 +29,17 @@ class SurveyManagementService( surveySaveRequest: SurveySaveRequest, makerId: UUID, ) { - val survey = surveyAdapter.getSurvey(surveyId) - // 현재 유저와 설문 제작자가 다를 경우 예외 발생 - if (survey.makerId != makerId) throw InvalidSurveyAccessException() - val rewards = surveySaveRequest.rewards.map { Reward(name = it.name, category = it.category, count = it.count) } + val survey = surveyAdapter.getByIdAndMakerId(surveyId, makerId) val newSurvey = with(surveySaveRequest) { - val sectionIds = SectionIds.from(surveySaveRequest.sections.map { SectionId.Standard(it.id) }) survey.updateContent( title = this.title, description = this.description, thumbnail = this.thumbnail, - finishedAt = this.finishedAt, finishMessage = this.finishMessage, - targetParticipantCount = this.targetParticipantCount, - rewards = rewards, - sections = this.sections.map { it.toDomain(sectionIds) }, + rewardSetting = this.rewardSetting.toDomain(survey.status), + isVisible = this.isVisible, + sections = this.sections.toDomain(), ) } surveyAdapter.save(newSurvey) @@ -56,9 +49,7 @@ class SurveyManagementService( surveyId: UUID, makerId: UUID, ): SurveyMakeInfoResponse { - val survey = surveyAdapter.getSurvey(surveyId) - // 현재 유저와 설문 제작자가 다를 경우 예외 발생 - if (survey.makerId != makerId) throw InvalidSurveyAccessException() + val survey = surveyAdapter.getByIdAndMakerId(surveyId, makerId) return SurveyMakeInfoResponse.of(survey) } @@ -67,16 +58,41 @@ class SurveyManagementService( surveyId: UUID, makerId: UUID, ) { - val survey = surveyAdapter.getSurvey(surveyId) - // 현재 유저와 설문 제작자가 다를 경우 예외 발생 - if (survey.makerId != makerId) throw InvalidSurveyAccessException() - surveyAdapter.save(survey.start()) - val drawingBoard = - DrawingBoard.create( - surveyId = survey.id, - boardSize = survey.targetParticipantCount, - rewards = survey.rewards, - ) - drawingBoardAdapter.save(drawingBoard) + val survey = surveyAdapter.getByIdAndMakerId(surveyId, makerId) + val startedSurvey = survey.start() + surveyAdapter.save(startedSurvey) + // 즉시 추첨이면서 최초 시작 시 추첨 보드 생성 + if (startedSurvey.rewardSetting is ImmediateDrawSetting && survey.status == SurveyStatus.NOT_STARTED) { + val drawingBoard = + DrawingBoard.create( + surveyId = startedSurvey.id, + boardSize = startedSurvey.rewardSetting.targetParticipantCount, + rewards = startedSurvey.rewardSetting.rewards, + ) + drawingBoardAdapter.save(drawingBoard) + } + } + + fun editSurvey( + surveyId: UUID, + makerId: UUID, + ) { + val survey = surveyAdapter.getByIdAndMakerId(surveyId, makerId) + surveyAdapter.save(survey.edit()) + } + + fun finishSurvey( + surveyId: UUID, + makerId: UUID, + ) { + val survey = surveyAdapter.getByIdAndMakerId(surveyId, makerId) + surveyAdapter.save(survey.finish()) + } + + fun deleteSurvey( + surveyId: UUID, + makerId: UUID, + ) { + surveyAdapter.delete(surveyId, makerId) } } diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyParticipantService.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyParticipantService.kt new file mode 100644 index 00000000..70f198df --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyParticipantService.kt @@ -0,0 +1,25 @@ +package com.sbl.sulmun2yong.survey.service + +import com.sbl.sulmun2yong.drawing.adapter.DrawingHistoryAdapter +import com.sbl.sulmun2yong.survey.adapter.ParticipantAdapter +import com.sbl.sulmun2yong.survey.adapter.SurveyAdapter +import com.sbl.sulmun2yong.survey.dto.response.ParticipantsInfoListResponse +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class SurveyParticipantService( + private val surveyAdapter: SurveyAdapter, + private val participantAdapter: ParticipantAdapter, + private val drawingHistoryAdapter: DrawingHistoryAdapter, +) { + fun getSurveyParticipants( + surveyId: UUID, + makerId: UUID, + ): ParticipantsInfoListResponse { + val survey = surveyAdapter.getByIdAndMakerId(surveyId, makerId) + val participants = participantAdapter.findBySurveyId(surveyId) + val drawingHistories = if (survey.isImmediateDraw()) drawingHistoryAdapter.getBySurveyId(surveyId, false) else null + return ParticipantsInfoListResponse.of(participants, drawingHistories, survey.rewardSetting.targetParticipantCount) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyResponseService.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyResponseService.kt index 6d30d400..c720c138 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyResponseService.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyResponseService.kt @@ -5,11 +5,9 @@ import com.sbl.sulmun2yong.survey.adapter.ParticipantAdapter import com.sbl.sulmun2yong.survey.adapter.ResponseAdapter import com.sbl.sulmun2yong.survey.adapter.SurveyAdapter import com.sbl.sulmun2yong.survey.domain.Participant -import com.sbl.sulmun2yong.survey.domain.SurveyStatus import com.sbl.sulmun2yong.survey.dto.request.SurveyResponseRequest import com.sbl.sulmun2yong.survey.dto.response.SurveyParticipantResponse import com.sbl.sulmun2yong.survey.exception.AlreadyParticipatedException -import com.sbl.sulmun2yong.survey.exception.SurveyClosedException import org.springframework.stereotype.Service import java.util.UUID @@ -32,18 +30,14 @@ class SurveyResponseService( validateIsAlreadyParticipated(surveyId, visitorId) fingerprintApi.validateVisitorId(visitorId) } - val survey = surveyAdapter.getSurvey(surveyId) - if (survey.status != SurveyStatus.IN_PROGRESS) { - throw SurveyClosedException() - } val surveyResponse = surveyResponseRequest.toDomain(surveyId) survey.validateResponse(surveyResponse) // TODO: 참가자 객체의 UserId에 실제 유저 값 넣기 val participant = Participant.create(visitorId, surveyId, null) - participantAdapter.saveParticipant(participant) - responseAdapter.saveSurveyResponse(surveyResponse, participant.id) - return SurveyParticipantResponse(participant.id) + participantAdapter.insert(participant) + responseAdapter.insertSurveyResponse(surveyResponse, participant.id) + return SurveyParticipantResponse(participant.id, survey.isImmediateDraw()) } private fun validateIsAlreadyParticipated( diff --git a/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyResultService.kt b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyResultService.kt new file mode 100644 index 00000000..8a9443cf --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/survey/service/SurveyResultService.kt @@ -0,0 +1,43 @@ +package com.sbl.sulmun2yong.survey.service + +import com.sbl.sulmun2yong.survey.adapter.ParticipantAdapter +import com.sbl.sulmun2yong.survey.adapter.ResponseAdapter +import com.sbl.sulmun2yong.survey.adapter.SurveyAdapter +import com.sbl.sulmun2yong.survey.dto.request.SurveyResultRequest +import com.sbl.sulmun2yong.survey.dto.response.SurveyResultResponse +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class SurveyResultService( + private val responseAdapter: ResponseAdapter, + private val surveyAdapter: SurveyAdapter, + private val participantAdapter: ParticipantAdapter, +) { + fun getSurveyResult( + surveyId: UUID, + makerId: UUID, + surveyResultRequest: SurveyResultRequest, + participantId: UUID?, + ): SurveyResultResponse { + val survey = surveyAdapter.getByIdAndMakerId(surveyId, makerId) + + // DB에서 설문 결과 조회 + val surveyResult = responseAdapter.getSurveyResult(surveyId, participantId) + + // 요청에 따라 설문 결과 필터링 + val resultFilter = surveyResultRequest.toDomain() + val filteredSurveyResult = surveyResult.getFilteredResult(resultFilter) + + val participantCount = + if (resultFilter.questionFilters.isEmpty()) { + // 필터를 걸지 않은 경우는 Participant Document에서 참가자 수 조회 + participantAdapter.findBySurveyId(surveyId).size + } else { + // 필터를 건 경우는 필터링된 결과 수로 참가자 수 조회 + surveyResult.getParticipantCount() + } + + return SurveyResultResponse.of(filteredSurveyResult, survey, participantCount) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/adapter/UserAdapter.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/adapter/UserAdapter.kt index 0a796bef..1d63d0ec 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/user/adapter/UserAdapter.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/adapter/UserAdapter.kt @@ -13,7 +13,11 @@ class UserAdapter( private val userRepository: UserRepository, ) { fun save(user: User) { - userRepository.save(UserDocument.of(user)) + val previousUserDocument = userRepository.findById(user.id) + val userDocument = UserDocument.of(user) + // 기존 유저를 업데이트하는 경우, createdAt을 유지 + if (previousUserDocument.isPresent) userDocument.createdAt = previousUserDocument.get().createdAt + userRepository.save(userDocument) } fun getById(id: UUID): User = diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/AdminController.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/AdminController.kt index 0a18fd49..35364cad 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/AdminController.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/AdminController.kt @@ -9,12 +9,14 @@ import org.springframework.http.ResponseEntity import org.springframework.security.core.session.SessionRegistry 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.RequestParam import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.bind.annotation.RestController import java.util.UUID -@RestController("/api/v1/admin") +@RestController +@RequestMapping("/api/v1/admin") class AdminController( private val sessionRegistry: SessionRegistry, private val adminService: AdminService, diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/LoginController.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/LoginController.kt new file mode 100644 index 00000000..be0c7349 --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/LoginController.kt @@ -0,0 +1,49 @@ +package com.sbl.sulmun2yong.user.controller + +import com.sbl.sulmun2yong.user.controller.doc.LoginApiDoc +import jakarta.servlet.http.HttpServletRequest +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +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.RequestParam +import org.springframework.web.bind.annotation.ResponseBody +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI + +@RestController +@RequestMapping("/api/v1/login") +class LoginController( + @Value("\${frontend.base-url}") + private val frontendBaseUrl: String, +) : LoginApiDoc { + @GetMapping("/oauth/{provider}") + @ResponseBody + override fun login( + @PathVariable provider: String, + @RequestParam("redirect_path") redirectPathAfterLogin: String?, + request: HttpServletRequest, + ): ResponseEntity { + val httpHeaders = HttpHeaders() + + val redirectUriAfterLogin = + redirectPathAfterLogin?. let { + URI.create(frontendBaseUrl + it) + } + + val redirectUriForOAuth2 = + UriComponentsBuilder + .fromPath("/oauth2/authorization/{provider}") + .queryParam("redirect_uri", redirectUriAfterLogin) + .buildAndExpand(provider) + .toUriString() + + httpHeaders.location = URI.create(redirectUriForOAuth2) + + return ResponseEntity(httpHeaders, HttpStatus.FOUND) + } +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/UserController.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/UserController.kt index 40fa123e..37b81743 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/UserController.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/UserController.kt @@ -6,11 +6,13 @@ import com.sbl.sulmun2yong.user.dto.response.UserProfileResponse import com.sbl.sulmun2yong.user.service.UserService import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.bind.annotation.RestController import java.util.UUID -@RestController("/api/v1/user") +@RestController +@RequestMapping("/api/v1/user") class UserController( private val userService: UserService, ) : UserApiDoc { diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/AdminApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/AdminApiDoc.kt index 854d1239..10813b3e 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/AdminApiDoc.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/AdminApiDoc.kt @@ -8,12 +8,10 @@ import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity 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.RequestParam import java.util.UUID @Tag(name = "Admin", description = "관리자 API") -@RequestMapping("/api/v1/admin") interface AdminApiDoc { @Operation(summary = "로그인한 사용자 조회") @GetMapping("/sessions/logged-in-users") diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/LoginApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/LoginApiDoc.kt new file mode 100644 index 00000000..a3c0b3fb --- /dev/null +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/LoginApiDoc.kt @@ -0,0 +1,22 @@ +package com.sbl.sulmun2yong.user.controller.doc + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseBody + +@Tag(name = "Login", description = "로그인 API") +interface LoginApiDoc { + @Operation(summary = "oauth 로그인") + @GetMapping("/login/{provider}") + @ResponseBody + fun login( + @PathVariable provider: String, + @RequestParam("redirect_path") redirectPathAfterLogin: String?, + request: HttpServletRequest, + ): ResponseEntity +} diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/UserApiDoc.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/UserApiDoc.kt index efaa6c2f..f0dcaef3 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/UserApiDoc.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/controller/doc/UserApiDoc.kt @@ -6,12 +6,10 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseBody import java.util.UUID @Tag(name = "User", description = "회원 API") -@RequestMapping("/api/v1/user") interface UserApiDoc { @Operation(summary = "내 정보 조회") @GetMapping("/profile") diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/domain/User.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/domain/User.kt index 2a2c3c38..e98384ee 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/user/domain/User.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/domain/User.kt @@ -14,11 +14,13 @@ data class User( var phoneNumber: PhoneNumber?, val role: UserRole, ) { - // TODO: UserRole이 AUTHENTICATED_USER일 경우 phoneNumber는 null이 아닌지 검사 init { if (nickname.length !in 2..10) { throw InvalidUserException() } + if (this.role == UserRole.ROLE_AUTHENTICATED_USER && phoneNumber == null) { + throw InvalidUserException() + } } companion object { diff --git a/src/main/kotlin/com/sbl/sulmun2yong/user/dto/DefaultUserProfile.kt b/src/main/kotlin/com/sbl/sulmun2yong/user/dto/DefaultUserProfile.kt index 16fdb966..8305efc2 100644 --- a/src/main/kotlin/com/sbl/sulmun2yong/user/dto/DefaultUserProfile.kt +++ b/src/main/kotlin/com/sbl/sulmun2yong/user/dto/DefaultUserProfile.kt @@ -1,12 +1,14 @@ package com.sbl.sulmun2yong.user.dto import com.fasterxml.jackson.databind.ObjectMapper +import com.sbl.sulmun2yong.user.domain.UserRole import java.util.Base64 import java.util.UUID data class DefaultUserProfile( val id: UUID, val nickname: String, + val role: UserRole, ) { fun toBase64Json(): String { val objectMapper = ObjectMapper() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9b3ff629..ade5f5de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,3 +35,13 @@ frontend: backend: base-url: http://localhost:8080 + +ai-server: + base-url: http://localhost:8000 + +cloudfront: + base-url: https://files.sulmoon.io + +mongock: + enabled: true + migration-scan-package: com.sbl.sulmun2yong.global.migration diff --git a/src/test/kotlin/com/sbl/sulmun2yong/drawing/domain/BoardMakingTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/drawing/domain/BoardMakingTest.kt index ff5a9700..b4c2e59c 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/drawing/domain/BoardMakingTest.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/drawing/domain/BoardMakingTest.kt @@ -3,7 +3,7 @@ package com.sbl.sulmun2yong.drawing.domain import com.sbl.sulmun2yong.drawing.domain.ticket.Ticket import com.sbl.sulmun2yong.drawing.exception.InvalidDrawingBoardException import com.sbl.sulmun2yong.fixture.drawing.DrawingBoardFixtureFactory.createDrawingBoard -import com.sbl.sulmun2yong.survey.domain.Reward +import com.sbl.sulmun2yong.survey.domain.reward.Reward import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.util.UUID diff --git a/src/test/kotlin/com/sbl/sulmun2yong/fixture/drawing/DrawingBoardFixtureFactory.kt b/src/test/kotlin/com/sbl/sulmun2yong/fixture/drawing/DrawingBoardFixtureFactory.kt index 214e6363..17a3a019 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/fixture/drawing/DrawingBoardFixtureFactory.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/fixture/drawing/DrawingBoardFixtureFactory.kt @@ -3,7 +3,7 @@ package com.sbl.sulmun2yong.fixture.drawing import com.sbl.sulmun2yong.drawing.domain.DrawingBoard import com.sbl.sulmun2yong.drawing.domain.ticket.Ticket import com.sbl.sulmun2yong.drawing.exception.InvalidDrawingBoardException -import com.sbl.sulmun2yong.survey.domain.Reward +import com.sbl.sulmun2yong.survey.domain.reward.Reward import java.util.UUID object DrawingBoardFixtureFactory { diff --git a/src/test/kotlin/com/sbl/sulmun2yong/fixture/survey/SurveyFixtureFactory.kt b/src/test/kotlin/com/sbl/sulmun2yong/fixture/survey/SurveyFixtureFactory.kt index cb26afe0..27a81419 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/fixture/survey/SurveyFixtureFactory.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/fixture/survey/SurveyFixtureFactory.kt @@ -1,13 +1,15 @@ package com.sbl.sulmun2yong.fixture.survey -import com.sbl.sulmun2yong.survey.domain.Reward +import com.sbl.sulmun2yong.global.util.DateUtil import com.sbl.sulmun2yong.survey.domain.Survey import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.domain.reward.Reward +import com.sbl.sulmun2yong.survey.domain.reward.RewardSetting +import com.sbl.sulmun2yong.survey.domain.reward.RewardSettingType import com.sbl.sulmun2yong.survey.domain.routing.RoutingStrategy import com.sbl.sulmun2yong.survey.domain.section.Section import com.sbl.sulmun2yong.survey.domain.section.SectionId import com.sbl.sulmun2yong.survey.domain.section.SectionIds -import java.time.Instant import java.util.Date import java.util.UUID @@ -16,7 +18,7 @@ object SurveyFixtureFactory { const val DESCRIPTION = "설문 설명" const val THUMBNAIL = "설문 썸네일" val SURVEY_STATUS = SurveyStatus.IN_PROGRESS - val FINISHED_AT = Date.from(Instant.now())!! + val FINISHED_AT = DateUtil.getCurrentDate(noMin = true) val PUBLISHED_AT = Date(FINISHED_AT.time - 24 * 60 * 60 * 10000) const val FINISH_MESSAGE = "설문이 종료되었습니다." const val TARGET_PARTICIPANT_COUNT = 100 @@ -40,7 +42,6 @@ object SurveyFixtureFactory { ), ) } - const val REWARD_COUNT = 9 fun createSurvey( id: UUID = UUID.randomUUID(), @@ -48,12 +49,14 @@ object SurveyFixtureFactory { description: String = DESCRIPTION, thumbnail: String = THUMBNAIL, publishedAt: Date? = PUBLISHED_AT, - finishedAt: Date = FINISHED_AT, + finishedAt: Date? = FINISHED_AT, status: SurveyStatus = SURVEY_STATUS, finishMessage: String = FINISH_MESSAGE, - targetParticipantCount: Int = TARGET_PARTICIPANT_COUNT, + targetParticipantCount: Int? = TARGET_PARTICIPANT_COUNT, + type: RewardSettingType = RewardSettingType.IMMEDIATE_DRAW, makerId: UUID = UUID.randomUUID(), rewards: List = REWARDS, + isVisible: Boolean = true, sections: List
= SECTIONS, ) = Survey( id = id, @@ -61,12 +64,19 @@ object SurveyFixtureFactory { description = description + id, thumbnail = thumbnail + id, publishedAt = publishedAt, - finishedAt = finishedAt, status = status, finishMessage = finishMessage + id, - targetParticipantCount = targetParticipantCount, makerId = makerId, - rewards = rewards, + rewardSetting = createRewardSetting(type, rewards, targetParticipantCount, finishedAt, status), + isVisible = isVisible, sections = sections, ) + + fun createRewardSetting( + type: RewardSettingType = RewardSettingType.IMMEDIATE_DRAW, + rewards: List = REWARDS, + targetParticipantCount: Int? = TARGET_PARTICIPANT_COUNT, + finishedAt: Date? = FINISHED_AT, + status: SurveyStatus = SURVEY_STATUS, + ) = RewardSetting.of(type, rewards, targetParticipantCount, finishedAt, status) } diff --git a/src/test/kotlin/com/sbl/sulmun2yong/fixture/survey/SurveyResultConstFactory.kt b/src/test/kotlin/com/sbl/sulmun2yong/fixture/survey/SurveyResultConstFactory.kt new file mode 100644 index 00000000..659ef63a --- /dev/null +++ b/src/test/kotlin/com/sbl/sulmun2yong/fixture/survey/SurveyResultConstFactory.kt @@ -0,0 +1,134 @@ +package com.sbl.sulmun2yong.fixture.survey + +import com.sbl.sulmun2yong.survey.domain.result.QuestionFilter +import com.sbl.sulmun2yong.survey.domain.result.QuestionResult +import com.sbl.sulmun2yong.survey.domain.result.ResultDetails +import com.sbl.sulmun2yong.survey.domain.result.SurveyResult +import java.util.UUID + +object SurveyResultConstFactory { + val JOB_QUESTION_ID = UUID.randomUUID() + val JOB_QUESTION_CONTENTS = listOf("학생", "직장인", "자영업자", "무직") + + val GENDER_QUESTION_ID = UUID.randomUUID() + val GENDER_QUESTION_CONTENTS = listOf("남자", "여자") + + val FOOD_MULTIPLE_CHOICE_QUESTION_ID = UUID.randomUUID() + val FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS = listOf("한식", "일식", "패스트푸드", "중식", "분식", "양식") + + val FOOD_TEXT_RESPONSE_QUESTION_ID = UUID.randomUUID() + val FOOD_TEXT_RESPONSE_QUESTION_CONTENTS = listOf("맛있어요!", "매콤해요!", "달아요!", "짭짤해요!") + + val PARTICIPANT_ID_1 = UUID.randomUUID() + + /** 1번 참가자의 응답(학생, 남자, 한식 & 일식, 맛있어요!) */ + val PARTICIPANT_RESULT_DETAILS_1 = + listOf( + ResultDetails( + participantId = PARTICIPANT_ID_1, + contents = listOf(JOB_QUESTION_CONTENTS[0]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_1, + contents = listOf(GENDER_QUESTION_CONTENTS[0]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_1, + contents = listOf(FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[0], FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[1]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_1, + contents = listOf(FOOD_TEXT_RESPONSE_QUESTION_CONTENTS[0]), + ), + ) + + val PARTICIPANT_ID_2 = UUID.randomUUID() + + /** 2번 참가자의 응답(직장인, 여자, 한식 & 패스트푸드 & 중식, 매콥해요!) */ + val PARTICIPANT_RESULT_DETAILS_2 = + listOf( + ResultDetails( + participantId = PARTICIPANT_ID_2, + contents = listOf(JOB_QUESTION_CONTENTS[1]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_2, + contents = listOf(GENDER_QUESTION_CONTENTS[1]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_2, + contents = + listOf( + FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[0], + FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[2], + FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[3], + ), + ), + ResultDetails( + participantId = PARTICIPANT_ID_2, + contents = listOf(FOOD_TEXT_RESPONSE_QUESTION_CONTENTS[1]), + ), + ) + + val PARTICIPANT_ID_3 = UUID.randomUUID() + + /** 3번 참가자의 응답(학생, 여자, 패스트푸드 & 분식, 달아요!) */ + val PARTICIPANT_RESULT_DETAILS_3 = + listOf( + ResultDetails( + participantId = PARTICIPANT_ID_3, + contents = listOf(JOB_QUESTION_CONTENTS[0]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_3, + contents = listOf(GENDER_QUESTION_CONTENTS[1]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_3, + contents = listOf(FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[2], FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[4]), + ), + ResultDetails( + participantId = PARTICIPANT_ID_3, + contents = listOf(FOOD_TEXT_RESPONSE_QUESTION_CONTENTS[2]), + ), + ) + + val QUESTION_RESULT_1 = + QuestionResult( + questionId = JOB_QUESTION_ID, + resultDetails = listOf(PARTICIPANT_RESULT_DETAILS_1[0], PARTICIPANT_RESULT_DETAILS_2[0], PARTICIPANT_RESULT_DETAILS_3[0]), + contents = JOB_QUESTION_CONTENTS.toSortedSet(), + ) + + val QUESTION_RESULT_2 = + QuestionResult( + questionId = GENDER_QUESTION_ID, + resultDetails = listOf(PARTICIPANT_RESULT_DETAILS_1[1], PARTICIPANT_RESULT_DETAILS_2[1], PARTICIPANT_RESULT_DETAILS_3[1]), + contents = GENDER_QUESTION_CONTENTS.toSortedSet(), + ) + + val QUESTION_RESULT_3 = + QuestionResult( + questionId = FOOD_MULTIPLE_CHOICE_QUESTION_ID, + resultDetails = listOf(PARTICIPANT_RESULT_DETAILS_1[2], PARTICIPANT_RESULT_DETAILS_2[2], PARTICIPANT_RESULT_DETAILS_3[2]), + contents = FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS.toSortedSet(), + ) + + val QUESTION_RESULT_4 = + QuestionResult( + questionId = FOOD_TEXT_RESPONSE_QUESTION_ID, + resultDetails = listOf(PARTICIPANT_RESULT_DETAILS_1[3], PARTICIPANT_RESULT_DETAILS_2[3], PARTICIPANT_RESULT_DETAILS_3[3]), + contents = FOOD_TEXT_RESPONSE_QUESTION_CONTENTS.toSortedSet(), + ) + + val SURVEY_RESULT = SurveyResult(questionResults = listOf(QUESTION_RESULT_1, QUESTION_RESULT_2, QUESTION_RESULT_3, QUESTION_RESULT_4)) + + val EXCEPT_STUDENT_FILTER = QuestionFilter(JOB_QUESTION_ID, listOf(JOB_QUESTION_CONTENTS[0]), false) + val MAN_FILTER = QuestionFilter(GENDER_QUESTION_ID, listOf(GENDER_QUESTION_CONTENTS[0]), true) + val K_J_FOOD_FILTER = + QuestionFilter( + FOOD_MULTIPLE_CHOICE_QUESTION_ID, + listOf(FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[0], FOOD_MULTIPLE_CHOICE_QUESTION_CONTENTS[1]), + true, + ) +} diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/ParticipantTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/ParticipantTest.kt index 6fc6ee1d..0b6695d4 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/ParticipantTest.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/ParticipantTest.kt @@ -2,6 +2,7 @@ package com.sbl.sulmun2yong.survey.domain import org.junit.jupiter.api.Test import org.mockito.Mockito +import java.util.Date import java.util.UUID import kotlin.test.assertEquals @@ -13,6 +14,7 @@ class ParticipantTest { val surveyId = UUID.randomUUID() val visitorId = "abcdefg" val userId = UUID.randomUUID() + val createdAt = Date() Mockito.mockStatic(UUID::class.java).use { mockedUUID -> mockedUUID.`when` { UUID.randomUUID() }.thenReturn(participantId) @@ -28,5 +30,7 @@ class ParticipantTest { assertEquals(userId, this.userId) } } + + assertEquals(createdAt, Participant(participantId, visitorId, surveyId, userId, createdAt).createdAt) } } diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/SurveyTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/SurveyTest.kt index e7ae941a..3c940168 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/SurveyTest.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/SurveyTest.kt @@ -1,41 +1,44 @@ package com.sbl.sulmun2yong.survey.domain +import com.sbl.sulmun2yong.fixture.survey.QuestionFixtureFactory import com.sbl.sulmun2yong.fixture.survey.SectionFixtureFactory.createMockSection +import com.sbl.sulmun2yong.fixture.survey.SectionFixtureFactory.createSection import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.DESCRIPTION import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.FINISHED_AT import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.FINISH_MESSAGE import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.PUBLISHED_AT -import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.REWARDS -import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.REWARD_COUNT import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.SECTIONS import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.SURVEY_STATUS -import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.TARGET_PARTICIPANT_COUNT import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.THUMBNAIL import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.TITLE +import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.createRewardSetting import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory.createSurvey import com.sbl.sulmun2yong.global.util.DateUtil import com.sbl.sulmun2yong.survey.domain.response.SectionResponse import com.sbl.sulmun2yong.survey.domain.response.SurveyResponse +import com.sbl.sulmun2yong.survey.domain.reward.FinishedAt +import com.sbl.sulmun2yong.survey.domain.reward.NoRewardSetting +import com.sbl.sulmun2yong.survey.domain.reward.Reward +import com.sbl.sulmun2yong.survey.domain.reward.RewardSetting +import com.sbl.sulmun2yong.survey.domain.reward.RewardSettingType import com.sbl.sulmun2yong.survey.domain.routing.RoutingStrategy import com.sbl.sulmun2yong.survey.domain.section.Section import com.sbl.sulmun2yong.survey.domain.section.SectionId import com.sbl.sulmun2yong.survey.domain.section.SectionIds +import com.sbl.sulmun2yong.survey.exception.InvalidPublishedAtException import com.sbl.sulmun2yong.survey.exception.InvalidSurveyException import com.sbl.sulmun2yong.survey.exception.InvalidSurveyResponseException import com.sbl.sulmun2yong.survey.exception.InvalidSurveyStartException import com.sbl.sulmun2yong.survey.exception.InvalidUpdateSurveyException +import com.sbl.sulmun2yong.survey.exception.SurveyClosedException import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows -import java.time.LocalDateTime -import java.time.ZoneId -import java.util.Date import java.util.UUID import kotlin.test.assertEquals class SurveyTest { private val id = UUID.randomUUID() - private val visitorId = "abcedfg" @Test fun `설문의 응답을 생성하면 정보들이 설정된다`() { @@ -78,47 +81,45 @@ class SurveyTest { assertEquals(TITLE + id, this.title) assertEquals(DESCRIPTION + id, this.description) assertEquals(THUMBNAIL + id, this.thumbnail) - assertEquals(FINISHED_AT, this.finishedAt) assertEquals(PUBLISHED_AT, this.publishedAt) assertEquals(SURVEY_STATUS, this.status) assertEquals(FINISH_MESSAGE + id, this.finishMessage) - assertEquals(TARGET_PARTICIPANT_COUNT, this.targetParticipantCount) + assertEquals(createRewardSetting(), this.rewardSetting) + assertEquals(true, this.isVisible) assertEquals(makerId, this.makerId) - assertEquals(REWARDS, this.rewards) assertEquals(SECTIONS, this.sections) } - val finishDate = - Date.from( - LocalDateTime - .now() - .plusDays(Survey.DEFAULT_SURVEY_DURATION) - .withSecond(0) - .withNano(0) - .atZone(ZoneId.systemDefault()) - .toInstant(), - ) - with(defaultSurvey) { assertEquals(Survey.DEFAULT_TITLE, this.title) assertEquals(Survey.DEFAULT_DESCRIPTION, this.description) assertEquals(null, this.thumbnail) - assertEquals(finishDate, this.finishedAt) assertEquals(null, this.publishedAt) assertEquals(SurveyStatus.NOT_STARTED, this.status) assertEquals(Survey.DEFAULT_FINISH_MESSAGE, this.finishMessage) - assertEquals(Survey.DEFAULT_TARGET_PARTICIPANT_COUNT, this.targetParticipantCount) + assertEquals(NoRewardSetting, this.rewardSetting) + assertEquals(true, this.isVisible) assertEquals(makerId, this.makerId) - assertEquals(listOf(Reward.create()), this.rewards) assertEquals(listOf(this.sections.first()), this.sections) } } @Test - fun `설문을 생성할 때 섹션이 1개 이상 없으면 예외가 발생한다`() { + fun `진행 중인 설문을 생성할 때 섹션이 1개 이상 없으면 예외가 발생한다`() { assertThrows { createSurvey(sections = listOf()) } } + @Test + fun `진행 중인 설문을 생성할 때 중복되는 선택지가 있으면 예외가 발생한다`() { + // given + val question1 = QuestionFixtureFactory.createTextResponseQuestion() + val question2 = QuestionFixtureFactory.createSingleChoiceQuestion(contents = listOf("1", "1")) + val section = createSection(questions = listOf(question1, question2)) + + // when, then + assertThrows { createSurvey(sections = listOf(section)) } + } + @Test fun `설문을 생성할 때 섹션들의 ID가 중복되면 예외가 발생한다`() { // given @@ -139,10 +140,36 @@ class SurveyTest { @Test fun `설문의 시작일이 마감일 이후면 예외가 발생한다`() { // given - val publishedAt = Date(FINISHED_AT.time + 24 * 60 * 60 * 1000) + val publishedAtAfterFinishedAt = DateUtil.getDateAfterDay(FINISHED_AT) // when, then - assertThrows { createSurvey(publishedAt = publishedAt) } + // 아직 시작하지 않은 경우 예외가 발생하지 않는다. + assertDoesNotThrow { + createSurvey( + publishedAt = null, + status = SurveyStatus.NOT_STARTED, + targetParticipantCount = null, + finishedAt = null, + rewards = emptyList(), + type = RewardSettingType.NO_REWARD, + ) + } + // 리워드 설정이 즉시 추첨인 설문은 시작일이 마감일 이후면 예외가 발생한다. + assertThrows { createSurvey(publishedAt = publishedAtAfterFinishedAt) } + // 리워드 설정이 직접 관리인 설문은 시작일이 마감일 이후면 예외가 발생한다. + assertThrows { + createSurvey(type = RewardSettingType.SELF_MANAGEMENT, publishedAt = publishedAtAfterFinishedAt, targetParticipantCount = null) + } + // 리워드 미 지급 설문은 마감일이 존재하지 않으므로 예외가 발생하지 않는다. + assertDoesNotThrow { + createSurvey( + type = RewardSettingType.NO_REWARD, + publishedAt = publishedAtAfterFinishedAt, + targetParticipantCount = null, + finishedAt = null, + rewards = emptyList(), + ) + } } @Test @@ -153,12 +180,6 @@ class SurveyTest { assertThrows { createSurvey(publishedAt = null, status = SurveyStatus.CLOSED) } } - // TODO: 추후에 리워드가 없는 설문도 생성할 수 있도록 수정하기 - @Test - fun `설문에는 최소 한 개의 리워드가 있어야한다`() { - assertThrows { createSurvey(rewards = emptyList()) } - } - @Test fun `설문은 응답의 섹션 순서가 유효한지 검증할 수 있다`() { // given @@ -173,6 +194,17 @@ class SurveyTest { val id = UUID.randomUUID() val survey = createSurvey(id = id, sections = listOf(section1, section2, section3)) + val notStartedSurvey = + createSurvey( + id = id, + sections = listOf(section1, section2, section3), + status = SurveyStatus.NOT_STARTED, + rewards = emptyList(), + targetParticipantCount = null, + finishedAt = null, + publishedAt = null, + type = RewardSettingType.NO_REWARD, + ) val surveyResponse1 = SurveyResponse( @@ -209,23 +241,8 @@ class SurveyTest { assertThrows { survey.validateResponse(surveyResponse2) } // 마지막 섹션을 응답하지 않은 경우 assertThrows { survey.validateResponse(surveyResponse3) } - } - - @Test - fun `설문은 설문의 리워드 개수를 계산할 수 있다`() { - // given - val survey = createSurvey() - - // when - val count = survey.getRewardCount() - - // then - assertEquals(REWARD_COUNT, count) - } - - @Test - fun `설문의 목표 참여자 수는 설문의 리워드 개수 이상이다`() { - assertThrows { createSurvey(targetParticipantCount = REWARD_COUNT - 1) } + // 설문이 시작되지 않은 경우 예외 발생 + assertThrows { notStartedSurvey.validateResponse(surveyResponse1) } } @Test @@ -244,6 +261,7 @@ class SurveyTest { // when, then assertDoesNotThrow { createSurvey(sections = listOf(section1, section2, section3)) } + assertDoesNotThrow { createSurvey(sections = listOf(), status = SurveyStatus.NOT_STARTED, publishedAt = null) } assertThrows { createSurvey(sections = listOf(section1, section2, section4)) } } @@ -260,6 +278,25 @@ class SurveyTest { assertEquals(SurveyStatus.CLOSED, finishedSurvey.status) } + @Test + fun `진행 중인 설문은 수정 중인 상태로 변경할 수 있다`() { + // given + val inProgressSurvey = + createSurvey(type = RewardSettingType.SELF_MANAGEMENT, status = SurveyStatus.IN_PROGRESS, targetParticipantCount = null) + val notStartedSurvey = + createSurvey(type = RewardSettingType.SELF_MANAGEMENT, status = SurveyStatus.NOT_STARTED, targetParticipantCount = null) + val inModificationSurvey = + createSurvey(type = RewardSettingType.SELF_MANAGEMENT, status = SurveyStatus.IN_MODIFICATION, targetParticipantCount = null) + val closedSurvey = + createSurvey(type = RewardSettingType.SELF_MANAGEMENT, status = SurveyStatus.CLOSED, targetParticipantCount = null) + + // when, then + assertEquals(SurveyStatus.IN_MODIFICATION, inProgressSurvey.edit().status) + assertThrows { notStartedSurvey.edit() } + assertThrows { inModificationSurvey.edit() } + assertThrows { closedSurvey.edit() } + } + @Test fun `설문의 내용를 업데이트할 수 있다`() { // given @@ -267,8 +304,14 @@ class SurveyTest { val newDescription = "new description" val newThumbnail = "new thumbnail" val newFinishMessage = "new finish message" - val newTargetParticipantCount = 10 - val newRewards = listOf(Reward("new reward", "new category", 1)) + val newRewardSetting = + RewardSetting.of( + type = RewardSettingType.IMMEDIATE_DRAW, + listOf(Reward("new reward", "new category", 1)), + 10, + DateUtil.getCurrentDate(noMin = true), + ) + val newIsVisible = false val sectionId = SectionId.Standard(UUID.randomUUID()) val newSections = listOf( @@ -289,10 +332,9 @@ class SurveyTest { title = newTitle, description = newDescription, thumbnail = newThumbnail, - finishedAt = survey.finishedAt, finishMessage = newFinishMessage, - targetParticipantCount = newTargetParticipantCount, - rewards = newRewards, + rewardSetting = newRewardSetting, + isVisible = newIsVisible, sections = listOf( Section( @@ -311,10 +353,9 @@ class SurveyTest { assertEquals(newTitle, this.title) assertEquals(newDescription, this.description) assertEquals(newThumbnail, this.thumbnail) - assertEquals(survey.finishedAt, this.finishedAt) assertEquals(newFinishMessage, this.finishMessage) - assertEquals(newTargetParticipantCount, this.targetParticipantCount) - assertEquals(newRewards, this.rewards) + assertEquals(newRewardSetting, this.rewardSetting) + assertEquals(isVisible, this.isVisible) assertEquals(newSections, this.sections) } } @@ -333,10 +374,9 @@ class SurveyTest { title = survey1.title, description = survey1.description, thumbnail = survey1.thumbnail, - finishedAt = survey1.finishedAt, finishMessage = survey1.finishMessage, - targetParticipantCount = survey1.targetParticipantCount, - rewards = survey1.rewards, + rewardSetting = survey1.rewardSetting, + isVisible = survey1.isVisible, sections = survey1.sections, ) } @@ -346,10 +386,9 @@ class SurveyTest { title = survey2.title, description = survey2.description, thumbnail = survey2.thumbnail, - finishedAt = survey2.finishedAt, finishMessage = survey2.finishMessage, - targetParticipantCount = survey2.targetParticipantCount, - rewards = survey2.rewards, + rewardSetting = survey2.rewardSetting, + isVisible = survey2.isVisible, sections = survey2.sections, ) } @@ -359,10 +398,9 @@ class SurveyTest { title = survey3.title, description = survey3.description, thumbnail = survey3.thumbnail, - finishedAt = survey3.finishedAt, finishMessage = survey3.finishMessage, - targetParticipantCount = survey3.targetParticipantCount, - rewards = survey3.rewards, + rewardSetting = survey3.rewardSetting, + isVisible = survey3.isVisible, sections = survey3.sections, ) } @@ -372,22 +410,9 @@ class SurveyTest { title = survey3.title, description = survey3.description, thumbnail = survey3.thumbnail, - finishedAt = survey3.finishedAt, - finishMessage = survey3.finishMessage, - targetParticipantCount = survey3.targetParticipantCount, - rewards = listOf(), - sections = survey3.sections, - ) - } - assertThrows { - survey3.updateContent( - title = survey3.title, - description = survey3.description, - thumbnail = survey3.thumbnail, - finishedAt = survey3.finishedAt, finishMessage = survey3.finishMessage, - targetParticipantCount = 1000, - rewards = survey3.rewards, + rewardSetting = NoRewardSetting, + isVisible = survey3.isVisible, sections = survey3.sections, ) } @@ -396,41 +421,86 @@ class SurveyTest { @Test fun `설문을 시작하면, 설문의 시작일과 상태가 업데이트된다`() { // given - val survey = createSurvey(finishedAt = DateUtil.getDateAfterDay(), publishedAt = null, status = SurveyStatus.NOT_STARTED) + val finishedAt = DateUtil.getDateAfterDay(date = DateUtil.getCurrentDate(noMin = true)) + val notStartedSurvey1 = + createSurvey( + type = RewardSettingType.SELF_MANAGEMENT, + finishedAt = finishedAt, + publishedAt = null, + targetParticipantCount = null, + status = SurveyStatus.NOT_STARTED, + ) + val notStartedSurvey2 = + createSurvey( + type = RewardSettingType.NO_REWARD, + finishedAt = null, + targetParticipantCount = null, + rewards = emptyList(), + publishedAt = null, + status = SurveyStatus.NOT_STARTED, + ) + val inModificationSurvey = + createSurvey( + type = RewardSettingType.NO_REWARD, + finishedAt = null, + targetParticipantCount = null, + status = SurveyStatus.IN_MODIFICATION, + rewards = emptyList(), + ) // when - val startedSurvey = survey.start() + val startedSurvey1 = notStartedSurvey1.start() + val startedSurvey2 = notStartedSurvey2.start() + val startedSurvey3 = inModificationSurvey.start() // then - assertEquals(DateUtil.getCurrentDate(), startedSurvey.publishedAt) - assertEquals(SurveyStatus.IN_PROGRESS, startedSurvey.status) + assertEquals(DateUtil.getCurrentDate(), startedSurvey1.publishedAt) + assertEquals(SurveyStatus.IN_PROGRESS, startedSurvey1.status) + assertEquals(FinishedAt(finishedAt), startedSurvey1.rewardSetting.finishedAt) + + assertEquals(DateUtil.getCurrentDate(), startedSurvey2.publishedAt) + assertEquals(SurveyStatus.IN_PROGRESS, startedSurvey2.status) + assertEquals(null, startedSurvey2.rewardSetting.finishedAt) + + assertEquals(inModificationSurvey.publishedAt, startedSurvey3.publishedAt) + assertEquals(SurveyStatus.IN_PROGRESS, startedSurvey3.status) + assertEquals(null, startedSurvey3.rewardSetting.finishedAt) + } + + @Test + fun `설문이 시작 전 상태나 수정 중인 상태가 아니면 시작할 수 없다`() { + // given + val inProgressSurvey = createSurvey(status = SurveyStatus.IN_PROGRESS) + val inModificationSurvey = createSurvey(status = SurveyStatus.CLOSED) + + // when, then + assertThrows { inProgressSurvey.start() } + assertThrows { inModificationSurvey.start() } } @Test - fun `설문이 시작 전 상태가 아니면 시작할 수 없다`() { + fun `설문은 설문의 추첨 방식이 즉시 추첨 방식인지 확인할 수 있다`() { // given val survey1 = createSurvey( - finishedAt = DateUtil.getDateAfterDay(), - publishedAt = DateUtil.getCurrentDate(), - status = SurveyStatus.IN_PROGRESS, + publishedAt = null, + status = SurveyStatus.NOT_STARTED, ) val survey2 = createSurvey( - finishedAt = DateUtil.getDateAfterDay(), - publishedAt = DateUtil.getCurrentDate(), - status = SurveyStatus.IN_MODIFICATION, - ) - val survey3 = - createSurvey( - finishedAt = DateUtil.getDateAfterDay(), - publishedAt = DateUtil.getCurrentDate(), - status = SurveyStatus.CLOSED, + finishedAt = DateUtil.getDateAfterDay(date = DateUtil.getCurrentDate(noMin = true)), + publishedAt = null, + status = SurveyStatus.NOT_STARTED, + targetParticipantCount = null, + type = RewardSettingType.SELF_MANAGEMENT, ) - // when, then - assertThrows { survey1.start() } - assertThrows { survey2.start() } - assertThrows { survey3.start() } + // when + val isImmediateDraw1 = survey1.isImmediateDraw() + val isImmediateDraw2 = survey2.isImmediateDraw() + + // then + assertEquals(true, isImmediateDraw1) + assertEquals(false, isImmediateDraw2) } } diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/ChoicesTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/ChoicesTest.kt index 00fab906..32d237ed 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/ChoicesTest.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/ChoicesTest.kt @@ -39,14 +39,28 @@ class ChoicesTest { } @Test - fun `선택지 목록의 내용은 중복될 수 없다`() { + fun `선택지는 최대 20개 까지만 추가할 수 있다`() { + val standardChoices = List(Choices.MAX_SIZE + 1) { Choice.Standard(it.toString()) } + assertThrows { Choices(standardChoices, true) } + assertThrows { Choices(standardChoices, false) } + } + + @Test + fun `선택지 목록에 중복된 내용이 있는지 확인할 수 있다`() { // given + val uniqueContents = listOf("1", "2", "3") val duplicatedContents1 = listOf("1", "2", "2") val duplicatedContents2 = listOf("3", "3") - // when, then - assertThrows { createChoices(duplicatedContents1, true) } - assertThrows { createChoices(duplicatedContents2, false) } + // when + val uniqueChoices = createChoices(uniqueContents, true) + val duplicatedChoices1 = createChoices(duplicatedContents1, true) + val duplicatedChoices2 = createChoices(duplicatedContents2, false) + + // then + assertEquals(true, uniqueChoices.isUnique()) + assertEquals(false, duplicatedChoices1.isUnique()) + assertEquals(false, duplicatedChoices2.isUnique()) } @Test diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/QuestionResponseTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/QuestionResultDetailsTest.kt similarity index 98% rename from src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/QuestionResponseTest.kt rename to src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/QuestionResultDetailsTest.kt index 636c2114..5f52fb6f 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/QuestionResponseTest.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/question/QuestionResultDetailsTest.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.api.assertThrows import java.util.UUID import kotlin.test.assertEquals -class QuestionResponseTest { +class QuestionResultDetailsTest { private val contentA = "A" private val contentB = "B" private val detailA = ResponseDetail(contentA) diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultFilterTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultFilterTest.kt new file mode 100644 index 00000000..76dffbca --- /dev/null +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/result/ResultFilterTest.kt @@ -0,0 +1,53 @@ +package com.sbl.sulmun2yong.survey.domain.result + +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.EXCEPT_STUDENT_FILTER +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.MAN_FILTER +import com.sbl.sulmun2yong.survey.exception.InvalidQuestionFilterException +import com.sbl.sulmun2yong.survey.exception.InvalidResultFilterException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID +import kotlin.test.assertEquals + +class ResultFilterTest { + @Test + fun `질문 필터를 생성하면 정보가 올바르게 설정된다`() { + // given + val questionId = UUID.randomUUID() + val contents = listOf("content1", "content2") + val isPositive = true + + // when + val questionFilter = QuestionFilter(questionId, contents, isPositive) + + // then + with(questionFilter) { + assertEquals(questionId, this.questionId) + assertEquals(contents, this.contents) + assertEquals(isPositive, this.isPositive) + } + } + + @Test + fun `질문 필터의 contents는 비어있을 수 없다`() { + assertThrows { QuestionFilter(UUID.randomUUID(), emptyList(), true) } + } + + @Test + fun `설문 결과 필터를 생성하면 정보가 올바르게 설정된다`() { + // given + val questionFilters = listOf(EXCEPT_STUDENT_FILTER, MAN_FILTER) + + // when + val resultFilter = ResultFilter(questionFilters) + + // then + assertEquals(questionFilters, resultFilter.questionFilters) + } + + @Test + fun `필터는 최대 20개 까지만 적용할 수 있다`() { + val questionFilters = List(ResultFilter.MAX_SIZE + 1) { QuestionFilter(UUID.randomUUID(), listOf("content"), true) } + assertThrows { ResultFilter(questionFilters) } + } +} diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/result/SurveyResultTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/result/SurveyResultTest.kt new file mode 100644 index 00000000..acd6ffc4 --- /dev/null +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/result/SurveyResultTest.kt @@ -0,0 +1,196 @@ +package com.sbl.sulmun2yong.survey.domain.result + +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.EXCEPT_STUDENT_FILTER +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.JOB_QUESTION_ID +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.K_J_FOOD_FILTER +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.MAN_FILTER +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.PARTICIPANT_RESULT_DETAILS_1 +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.PARTICIPANT_RESULT_DETAILS_2 +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.PARTICIPANT_RESULT_DETAILS_3 +import com.sbl.sulmun2yong.fixture.survey.SurveyResultConstFactory.SURVEY_RESULT +import com.sbl.sulmun2yong.survey.exception.InvalidResultDetailsException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SurveyResultTest { + @Test + fun `설문 결과를 생성하면 정보가 올바르게 설정된다`() { + // given + val questionId1 = UUID.randomUUID() + val questionId2 = UUID.randomUUID() + val participantId1 = UUID.randomUUID() + val participantId2 = UUID.randomUUID() + val contents1 = listOf("content1") + val contents2 = listOf("content2") + val contents3 = listOf("content3") + + val response1 = + ResultDetails( + participantId = participantId1, + contents = contents1, + ) + val response2 = + ResultDetails( + participantId = participantId1, + contents = contents2, + ) + val response3 = + ResultDetails( + participantId = participantId2, + contents = contents3, + ) + + val questionContents1 = sortedSetOf(contents1.first(), contents3.first()) + val questionResult1 = + QuestionResult( + questionId = questionId1, + resultDetails = listOf(response1, response3), + contents = questionContents1, + ) + val questionContents2 = sortedSetOf(contents2.first()) + val questionResult2 = + QuestionResult( + questionId = questionId2, + resultDetails = listOf(response2), + contents = sortedSetOf(contents2.first()), + ) + + // when + val surveyResult = SurveyResult(listOf(questionResult1, questionResult2)) + + // then + with(surveyResult.questionResults[0]) { + assertEquals(questionId1, questionId) + assertEquals(questionContents1, contents) + assertEquals(contents1, resultDetails[0].contents) + assertEquals(participantId1, resultDetails[0].participantId) + assertEquals(contents3, resultDetails[1].contents) + assertEquals(participantId2, resultDetails[1].participantId) + } + with(surveyResult.questionResults[1]) { + assertEquals(questionId2, questionId) + assertEquals(questionContents2, contents) + assertEquals(contents2, resultDetails[0].contents) + assertEquals(participantId1, resultDetails[0].participantId) + } + } + + @Test + fun `설문 결과 상세의 contents는 비어있을 수 없다`() { + // given + val participantId = UUID.randomUUID() + val contents = emptyList() + + // when, then + assertThrows { + ResultDetails(participantId, contents) + } + } + + @Test + fun `설문 결과 상세는 질문 필터를 받으면 필터링되는 응답인지 확인할 수 있다`() { + // given + val jobQuestionDetails1 = PARTICIPANT_RESULT_DETAILS_1[0] + val genderQuestionDetails1 = PARTICIPANT_RESULT_DETAILS_1[1] + val foodQuestionDetails1 = PARTICIPANT_RESULT_DETAILS_1[2] + + val jobQuestionDetails2 = PARTICIPANT_RESULT_DETAILS_2[0] + val genderQuestionDetails2 = PARTICIPANT_RESULT_DETAILS_2[1] + val foodQuestionDetails2 = PARTICIPANT_RESULT_DETAILS_2[2] + + val jobQuestionDetails3 = PARTICIPANT_RESULT_DETAILS_3[0] + val genderQuestionDetails3 = PARTICIPANT_RESULT_DETAILS_3[1] + val foodQuestionDetails3 = PARTICIPANT_RESULT_DETAILS_3[2] + + // when + // isMatched는 질문 필터의 contents에 응답의 contents가 포함되어 있고, + // 질문 필터의 questionId가 응답의 questionId가 같은지 판단 + val isStudent1 = jobQuestionDetails1.isMatched(EXCEPT_STUDENT_FILTER) + val isMan1 = genderQuestionDetails1.isMatched(MAN_FILTER) + val isKOrJFood1 = foodQuestionDetails1.isMatched(K_J_FOOD_FILTER) + + val isStudent2 = jobQuestionDetails2.isMatched(EXCEPT_STUDENT_FILTER) + val isMan2 = genderQuestionDetails2.isMatched(MAN_FILTER) + val isKOrJFood2 = foodQuestionDetails2.isMatched(K_J_FOOD_FILTER) + + val isStudent3 = jobQuestionDetails3.isMatched(EXCEPT_STUDENT_FILTER) + val isMan3 = genderQuestionDetails3.isMatched(MAN_FILTER) + val isKOrJFood3 = foodQuestionDetails3.isMatched(K_J_FOOD_FILTER) + + // then + // 1번 참가자의 응답(학생, 남자, 한식 & 일식, 맛있어요!) + assertEquals(true, isStudent1) + assertEquals(true, isMan1) + assertEquals(true, isKOrJFood1) + + // 2번 참가자의 응답(직장인, 여자, 한식 & 패스트푸드 & 중식, 매콥해요!) + assertEquals(false, isStudent2) + assertEquals(false, isMan2) + assertEquals(true, isKOrJFood2) + + // 3번 참가자의 응답(학생, 여자, 패스트푸드 & 분식, 달아요!) + assertEquals(true, isStudent3) + assertEquals(false, isMan3) + assertEquals(false, isKOrJFood3) + } + + @Test + fun `설문 결과는 설문 결과 필터를 받으면 필터링된 결과를 반환한다`() { + // given + val surveyResult = SURVEY_RESULT + val exceptStudentAndKOrJFoodFilter = ResultFilter(listOf(EXCEPT_STUDENT_FILTER, K_J_FOOD_FILTER)) + val exceptStudentAndManAndKOrJFoodFilter = ResultFilter(listOf(EXCEPT_STUDENT_FILTER, MAN_FILTER, K_J_FOOD_FILTER)) + val invalidFilter = ResultFilter(listOf(QuestionFilter(UUID.randomUUID(), listOf("invalid_content"), true))) + + // when + val filteredResult1 = surveyResult.getFilteredResult(exceptStudentAndKOrJFoodFilter) + val filteredResult2 = surveyResult.getFilteredResult(exceptStudentAndManAndKOrJFoodFilter) + val filteredResult3 = surveyResult.getFilteredResult(invalidFilter) + + // then + with(filteredResult1.questionResults.map { it.resultDetails }.flatten()) { + assertTrue { this.containsAll(PARTICIPANT_RESULT_DETAILS_2) } + } + with(filteredResult2.questionResults.map { it.resultDetails }.flatten()) { + assertEquals(0, size) + } + // 이상한 필터는 무시한다. + with(filteredResult3.questionResults.map { it.resultDetails }.flatten()) { + assertTrue { this.containsAll(PARTICIPANT_RESULT_DETAILS_1) } + assertTrue { this.containsAll(PARTICIPANT_RESULT_DETAILS_2) } + assertTrue { this.containsAll(PARTICIPANT_RESULT_DETAILS_3) } + } + } + + @Test + fun `설문 결과는 질문 ID를 받으면 해당 질문의 응답들만 반환한다`() { + // given + val surveyResult = SURVEY_RESULT + + // when + val jobQuestionResponses = surveyResult.findQuestionResult(JOB_QUESTION_ID) + + // then + assertEquals(3, jobQuestionResponses?.resultDetails?.size) + with(jobQuestionResponses!!.resultDetails.map { it.contents }.flatten()) { + assertEquals(true, containsAll(PARTICIPANT_RESULT_DETAILS_1[0].contents)) + assertEquals(true, containsAll(PARTICIPANT_RESULT_DETAILS_2[0].contents)) + assertEquals(true, containsAll(PARTICIPANT_RESULT_DETAILS_3[0].contents)) + } + } + + @Test + fun `설문 결과는 해당 설문의 참여자 수를 가져올 수 있다`() { + // given + val surveyResult = SURVEY_RESULT + + // when + val participantCount = surveyResult.getParticipantCount() + + // then + assertEquals(3, participantCount) + } +} diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSettingTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSettingTest.kt new file mode 100644 index 00000000..2308cf3d --- /dev/null +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardSettingTest.kt @@ -0,0 +1,199 @@ +package com.sbl.sulmun2yong.survey.domain.reward + +import com.sbl.sulmun2yong.fixture.survey.SurveyFixtureFactory +import com.sbl.sulmun2yong.global.util.DateUtil +import com.sbl.sulmun2yong.survey.domain.SurveyStatus +import com.sbl.sulmun2yong.survey.exception.InvalidFinishedAtException +import com.sbl.sulmun2yong.survey.exception.InvalidRewardSettingException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.Calendar +import kotlin.test.assertEquals + +class RewardSettingTest { + @Test + fun `리워드 설정을 생성하면 정보가 올바르게 설정된다`() { + // given + val rewards = SurveyFixtureFactory.REWARDS + val targetParticipantCount = SurveyFixtureFactory.TARGET_PARTICIPANT_COUNT + val finishedAt = SurveyFixtureFactory.FINISHED_AT + + // when + val immediateRewardSetting1 = ImmediateDrawSetting(rewards, targetParticipantCount, FinishedAt(finishedAt)) + val immediateRewardSetting2 = RewardSetting.of(RewardSettingType.IMMEDIATE_DRAW, rewards, targetParticipantCount, finishedAt) + val selfManagementSetting1 = SelfManagementSetting(rewards, FinishedAt(finishedAt)) + val selfManagementSetting2 = RewardSetting.of(RewardSettingType.SELF_MANAGEMENT, rewards, null, finishedAt) + val noRewardSetting1 = NoRewardSetting + val noRewardSetting2 = RewardSetting.of(RewardSettingType.NO_REWARD, listOf(), null, null) + val notStartedRewardSetting1 = + RewardSetting.of(RewardSettingType.IMMEDIATE_DRAW, listOf(), targetParticipantCount, null, SurveyStatus.NOT_STARTED) + val notStartedRewardSetting2 = + RewardSetting.of(RewardSettingType.NO_REWARD, rewards, null, finishedAt, SurveyStatus.NOT_STARTED) + + // then + // 즉시 추첨 + with(immediateRewardSetting1) { + assertEquals(RewardSettingType.IMMEDIATE_DRAW, this.type) + assertEquals(rewards, this.rewards) + assertEquals(targetParticipantCount, this.targetParticipantCount) + assertEquals(FinishedAt(finishedAt), this.finishedAt) + assertEquals(true, this.isImmediateDraw) + } + with(immediateRewardSetting2) { + assertEquals(RewardSettingType.IMMEDIATE_DRAW, this.type) + assertEquals(rewards, this.rewards) + assertEquals(targetParticipantCount, this.targetParticipantCount) + assertEquals(FinishedAt(finishedAt), this.finishedAt) + assertEquals(true, this.isImmediateDraw) + } + // 직접 추첨 + with(selfManagementSetting1) { + assertEquals(RewardSettingType.SELF_MANAGEMENT, this.type) + assertEquals(rewards, this.rewards) + assertEquals(null, this.targetParticipantCount) + assertEquals(FinishedAt(finishedAt), this.finishedAt) + assertEquals(false, this.isImmediateDraw) + } + with(selfManagementSetting2) { + assertEquals(RewardSettingType.SELF_MANAGEMENT, this.type) + assertEquals(rewards, this.rewards) + assertEquals(null, this.targetParticipantCount) + assertEquals(FinishedAt(finishedAt), this.finishedAt) + assertEquals(false, this.isImmediateDraw) + } + // 리워드 미 지급 + with(noRewardSetting1) { + assertEquals(RewardSettingType.NO_REWARD, this.type) + assertEquals(emptyList(), this.rewards) + assertEquals(null, this.targetParticipantCount) + assertEquals(null, this.finishedAt) + assertEquals(false, this.isImmediateDraw) + } + with(noRewardSetting2) { + assertEquals(RewardSettingType.NO_REWARD, this.type) + assertEquals(emptyList(), this.rewards) + assertEquals(null, this.targetParticipantCount) + assertEquals(null, this.finishedAt) + assertEquals(false, this.isImmediateDraw) + } + // 시작 전 상태의 불완전한 리워드 지급 설정 + with(notStartedRewardSetting1) { + assertEquals(RewardSettingType.IMMEDIATE_DRAW, this.type) + assertEquals(emptyList(), this.rewards) + assertEquals(targetParticipantCount, this.targetParticipantCount) + assertEquals(null, this.finishedAt) + assertEquals(true, this.isImmediateDraw) + } + with(notStartedRewardSetting2) { + assertEquals(RewardSettingType.NO_REWARD, this.type) + assertEquals(rewards, this.rewards) + assertEquals(null, this.targetParticipantCount) + assertEquals(FinishedAt(finishedAt), this.finishedAt) + assertEquals(false, this.isImmediateDraw) + } + } + + @Test + fun `리워드 설정를 잘못 생성하면 예외가 발생한다`() { + // 리워드 미지급 + with(RewardSettingType.NO_REWARD) { + // 리워드가 존재하는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, SurveyFixtureFactory.REWARDS, null, null) + } + // targetParticipant가 존재하는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, emptyList(), 100, null) + } + // finishedAt이 존재하는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, emptyList(), null, SurveyFixtureFactory.FINISHED_AT) + } + } + // 직접 지급 + with(RewardSettingType.SELF_MANAGEMENT) { + // 리워드가 없는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, emptyList(), 100, SurveyFixtureFactory.FINISHED_AT) + } + // targetParticipant가 존재하는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, SurveyFixtureFactory.REWARDS, 100, SurveyFixtureFactory.FINISHED_AT) + } + // finishedAt이 없는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, SurveyFixtureFactory.REWARDS, null, null) + } + } + // 즉시 추첨 + with(RewardSettingType.IMMEDIATE_DRAW) { + // 리워드가 없는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, emptyList(), 100, SurveyFixtureFactory.FINISHED_AT) + } + // targetParticipant가 존재하지 않는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, SurveyFixtureFactory.REWARDS, null, SurveyFixtureFactory.FINISHED_AT) + } + // finishedAt이 없는 경우 예외 발생 + assertThrows { + RewardSetting.of(this, SurveyFixtureFactory.REWARDS, 100, null) + } + } + } + + @Test + fun `즉시 추첨은 리워드가 하나 이상 존재해야한다`() { + assertThrows { + ImmediateDrawSetting(listOf(), 10, FinishedAt(SurveyFixtureFactory.FINISHED_AT)) + } + } + + @Test + fun `즉시 추첨은 리워드 개수의 총합이 목표 참여자 수보다 적어야한다`() { + assertThrows { + ImmediateDrawSetting(SurveyFixtureFactory.REWARDS, 1, FinishedAt(SurveyFixtureFactory.FINISHED_AT)) + } + } + + @Test + fun `직접 지급은 리워드가 하나 이상 존재해야한다`() { + assertThrows { + SelfManagementSetting(listOf(), FinishedAt(SurveyFixtureFactory.FINISHED_AT)) + } + } + + @Test + fun `설문 종료일을 생성하면 정보가 올바르게 설정된다`() { + // given + val date = SurveyFixtureFactory.FINISHED_AT + + // when + val finishedAt = FinishedAt(date) + + // then + assertEquals(date, finishedAt.value) + } + + @Test + fun `설문 종료일은 분 단위 이하가 0이여야 한다`() { + // given + val calendar = Calendar.getInstance() + calendar.time = DateUtil.getCurrentDate() + calendar.set(Calendar.MINUTE, 1) + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 0) + val date1 = calendar.time + calendar.set(Calendar.MINUTE, 0) + calendar.set(Calendar.SECOND, 1) + val date2 = calendar.time + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 1) + val date3 = calendar.time + + // when, then + assertThrows { FinishedAt(date1) } + assertThrows { FinishedAt(date2) } + assertThrows { FinishedAt(date3) } + } +} diff --git a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/RewardTest.kt b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardTest.kt similarity index 96% rename from src/test/kotlin/com/sbl/sulmun2yong/survey/domain/RewardTest.kt rename to src/test/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardTest.kt index 7e892c95..c9095915 100644 --- a/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/RewardTest.kt +++ b/src/test/kotlin/com/sbl/sulmun2yong/survey/domain/reward/RewardTest.kt @@ -1,4 +1,4 @@ -package com.sbl.sulmun2yong.survey.domain +package com.sbl.sulmun2yong.survey.domain.reward import com.sbl.sulmun2yong.survey.exception.InvalidRewardException import org.junit.jupiter.api.Test