From de7746afd2f5eeff2af6fe744225ef4e134d1641 Mon Sep 17 00:00:00 2001 From: rktclgh Date: Sat, 14 Mar 2026 04:01:52 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9/=EB=AC=B4=EC=A1=B0=EA=B1=B4=20=EC=83=9D=EC=84=B1=20po?= =?UTF-8?q?licy=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InterviewAdminController.kt | 21 +- .../dto/AdminInterviewSettingsDtos.kt | 13 + .../interview/entity/AdminInterviewSetting.kt | 36 ++ .../entity/TechQuestionReusePolicy.kt | 6 + .../AdminInterviewSettingRepository.kt | 6 + .../service/AdminInterviewSettingsService.kt | 71 ++++ .../service/InterviewPracticeService.kt | 22 +- .../AdminInterviewSettingsServiceTests.kt | 78 ++++ .../service/InterviewPracticeServiceTests.kt | 365 ++++++++++++++++++ 9 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/cw/vlainter/domain/interview/dto/AdminInterviewSettingsDtos.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/interview/entity/AdminInterviewSetting.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/interview/entity/TechQuestionReusePolicy.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/interview/repository/AdminInterviewSettingRepository.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt create mode 100644 src/test/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsServiceTests.kt create mode 100644 src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/controller/InterviewAdminController.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/controller/InterviewAdminController.kt index b27376b..748dcdd 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/controller/InterviewAdminController.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/controller/InterviewAdminController.kt @@ -1,13 +1,16 @@ package com.cw.vlainter.domain.interview.controller import com.cw.vlainter.domain.interview.dto.AdminQuestionSetSummaryResponse +import com.cw.vlainter.domain.interview.dto.AdminInterviewSettingsResponse import com.cw.vlainter.domain.interview.dto.AdminCategoryResponse import com.cw.vlainter.domain.interview.dto.CreateCategoryRequest import com.cw.vlainter.domain.interview.dto.MergeCategoryRequest import com.cw.vlainter.domain.interview.dto.MoveCategoryRequest import com.cw.vlainter.domain.interview.dto.QuestionSetSummaryResponse +import com.cw.vlainter.domain.interview.dto.UpdateAdminInterviewSettingsRequest import com.cw.vlainter.domain.interview.dto.UpdateCategoryRequest import com.cw.vlainter.domain.interview.dto.UpdateQuestionSetRequest +import com.cw.vlainter.domain.interview.service.AdminInterviewSettingsService import com.cw.vlainter.domain.interview.service.CategoryAdminService import com.cw.vlainter.domain.interview.service.QuestionSetService import com.cw.vlainter.global.security.AuthPrincipal @@ -28,8 +31,24 @@ import org.springframework.web.bind.annotation.RequestParam @RequestMapping("/api/admin/interview") class InterviewAdminController( private val questionSetService: QuestionSetService, - private val categoryAdminService: CategoryAdminService + private val categoryAdminService: CategoryAdminService, + private val adminInterviewSettingsService: AdminInterviewSettingsService ) { + @GetMapping("/settings") + fun getSettings( + @AuthenticationPrincipal principal: AuthPrincipal + ): ResponseEntity { + return ResponseEntity.ok(adminInterviewSettingsService.getSettings(principal)) + } + + @PatchMapping("/settings") + fun updateSettings( + @AuthenticationPrincipal principal: AuthPrincipal, + @Valid @RequestBody request: UpdateAdminInterviewSettingsRequest + ): ResponseEntity { + return ResponseEntity.ok(adminInterviewSettingsService.updateSettings(principal, request)) + } + @GetMapping("/sets") fun getAllSets( @AuthenticationPrincipal principal: AuthPrincipal, diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/dto/AdminInterviewSettingsDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/dto/AdminInterviewSettingsDtos.kt new file mode 100644 index 0000000..6a55989 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/dto/AdminInterviewSettingsDtos.kt @@ -0,0 +1,13 @@ +package com.cw.vlainter.domain.interview.dto + +import com.cw.vlainter.domain.interview.entity.TechQuestionReusePolicy +import java.time.OffsetDateTime + +data class AdminInterviewSettingsResponse( + val techQuestionReusePolicy: TechQuestionReusePolicy, + val updatedAt: OffsetDateTime? +) + +data class UpdateAdminInterviewSettingsRequest( + val techQuestionReusePolicy: TechQuestionReusePolicy +) diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/entity/AdminInterviewSetting.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/entity/AdminInterviewSetting.kt new file mode 100644 index 0000000..59df39b --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/entity/AdminInterviewSetting.kt @@ -0,0 +1,36 @@ +@file:Suppress("JpaDataSourceORMInspection") + +package com.cw.vlainter.domain.interview.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate +import jakarta.persistence.Table +import java.time.OffsetDateTime + +@Entity +@Table(name = "admin_interview_settings") +class AdminInterviewSetting( + @Suppress("unused") + @Id + @Column(name = "setting_key", nullable = false, length = 100) + val settingKey: String, + + @Column(name = "setting_value", nullable = false, length = 100) + var settingValue: String, + + @Column(name = "updated_at", nullable = false) + var updatedAt: OffsetDateTime = OffsetDateTime.now() +) { + @PrePersist + fun prePersist() { + updatedAt = OffsetDateTime.now() + } + + @PreUpdate + fun preUpdate() { + updatedAt = OffsetDateTime.now() + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/entity/TechQuestionReusePolicy.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/entity/TechQuestionReusePolicy.kt new file mode 100644 index 0000000..b8da32a --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/entity/TechQuestionReusePolicy.kt @@ -0,0 +1,6 @@ +package com.cw.vlainter.domain.interview.entity + +enum class TechQuestionReusePolicy { + REUSE_MATCHING, + ALWAYS_GENERATE +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/repository/AdminInterviewSettingRepository.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/repository/AdminInterviewSettingRepository.kt new file mode 100644 index 0000000..35b8502 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/repository/AdminInterviewSettingRepository.kt @@ -0,0 +1,6 @@ +package com.cw.vlainter.domain.interview.repository + +import com.cw.vlainter.domain.interview.entity.AdminInterviewSetting +import org.springframework.data.jpa.repository.JpaRepository + +interface AdminInterviewSettingRepository : JpaRepository diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt new file mode 100644 index 0000000..2f6555d --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt @@ -0,0 +1,71 @@ +package com.cw.vlainter.domain.interview.service + +import com.cw.vlainter.domain.interview.dto.AdminInterviewSettingsResponse +import com.cw.vlainter.domain.interview.dto.UpdateAdminInterviewSettingsRequest +import com.cw.vlainter.domain.interview.entity.AdminInterviewSetting +import com.cw.vlainter.domain.interview.entity.TechQuestionReusePolicy +import com.cw.vlainter.domain.interview.repository.AdminInterviewSettingRepository +import com.cw.vlainter.domain.user.entity.UserRole +import com.cw.vlainter.global.security.AuthPrincipal +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException + +@Service +class AdminInterviewSettingsService( + private val adminInterviewSettingRepository: AdminInterviewSettingRepository +) { + @Transactional(readOnly = true) + fun getSettings(principal: AuthPrincipal): AdminInterviewSettingsResponse { + ensureAdmin(principal) + val saved = adminInterviewSettingRepository.findById(TECH_QUESTION_REUSE_POLICY_KEY).orElse(null) + return AdminInterviewSettingsResponse( + techQuestionReusePolicy = saved?.toTechQuestionReusePolicy() ?: DEFAULT_TECH_QUESTION_REUSE_POLICY, + updatedAt = saved?.updatedAt + ) + } + + @Transactional + fun updateSettings( + principal: AuthPrincipal, + request: UpdateAdminInterviewSettingsRequest + ): AdminInterviewSettingsResponse { + ensureAdmin(principal) + val saved = adminInterviewSettingRepository.findById(TECH_QUESTION_REUSE_POLICY_KEY) + .orElseGet { + AdminInterviewSetting( + settingKey = TECH_QUESTION_REUSE_POLICY_KEY, + settingValue = request.techQuestionReusePolicy.name + ) + } + saved.settingValue = request.techQuestionReusePolicy.name + val updated = adminInterviewSettingRepository.save(saved) + return AdminInterviewSettingsResponse( + techQuestionReusePolicy = updated.toTechQuestionReusePolicy(), + updatedAt = updated.updatedAt + ) + } + + @Transactional(readOnly = true) + fun getTechQuestionReusePolicy(): TechQuestionReusePolicy { + val saved = adminInterviewSettingRepository.findById(TECH_QUESTION_REUSE_POLICY_KEY).orElse(null) + return saved?.toTechQuestionReusePolicy() ?: DEFAULT_TECH_QUESTION_REUSE_POLICY + } + + private fun AdminInterviewSetting.toTechQuestionReusePolicy(): TechQuestionReusePolicy { + return runCatching { TechQuestionReusePolicy.valueOf(settingValue) } + .getOrDefault(DEFAULT_TECH_QUESTION_REUSE_POLICY) + } + + private fun ensureAdmin(principal: AuthPrincipal) { + if (principal.role != UserRole.ADMIN) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "관리자만 접근할 수 있습니다.") + } + } + + companion object { + const val TECH_QUESTION_REUSE_POLICY_KEY = "tech_question_reuse_policy" + val DEFAULT_TECH_QUESTION_REUSE_POLICY: TechQuestionReusePolicy = TechQuestionReusePolicy.ALWAYS_GENERATE + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt index d8544a7..e1950e3 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt @@ -29,6 +29,7 @@ import com.cw.vlainter.domain.interview.entity.QuestionSetVisibility import com.cw.vlainter.domain.interview.entity.QuestionSourceTag import com.cw.vlainter.domain.interview.entity.RevealPolicy import com.cw.vlainter.domain.interview.entity.SavedQuestion +import com.cw.vlainter.domain.interview.entity.TechQuestionReusePolicy import com.cw.vlainter.domain.interview.entity.TurnSourceTag import com.cw.vlainter.domain.interview.ai.AiRoutingContextHolder import com.cw.vlainter.domain.interview.ai.InterviewAiOrchestrator @@ -80,6 +81,7 @@ class InterviewPracticeService( private val savedQuestionRepository: SavedQuestionRepository, private val userRepository: UserRepository, private val userGeminiApiKeyService: UserGeminiApiKeyService, + private val adminInterviewSettingsService: AdminInterviewSettingsService, private val objectMapper: ObjectMapper, private val entityManager: EntityManager ) { @@ -108,7 +110,16 @@ class InterviewPracticeService( } else { null } - var candidates = resolveCandidates(principal, request, categoryContext?.category?.id) + val techQuestionReusePolicy = if (request.setId == null) { + adminInterviewSettingsService.getTechQuestionReusePolicy() + } else { + TechQuestionReusePolicy.REUSE_MATCHING + } + var candidates = if (shouldReuseMatchingQuestions(request, techQuestionReusePolicy)) { + resolveCandidates(principal, request, categoryContext?.category?.id) + } else { + emptyList() + } if (candidates.isEmpty() && request.setId == null) { categoryContext = categoryContext ?: categoryContextResolver.resolve( @@ -169,6 +180,7 @@ class InterviewPracticeService( "categoryName" to resolvedSkillName, "jobName" to resolvedJobName, "practiceMode" to practiceMode.name, + "techQuestionReusePolicy" to techQuestionReusePolicy.name, "selectedDocuments" to emptyList>(), "localizedQueue" to localizedQueue, "providerUsed" to aiRoutingContextHolder.snapshot().providerUsed?.name, @@ -557,6 +569,14 @@ class InterviewPracticeService( } } + private fun shouldReuseMatchingQuestions( + request: StartTechInterviewRequest, + techQuestionReusePolicy: TechQuestionReusePolicy + ): Boolean { + if (request.setId != null) return true + return techQuestionReusePolicy == TechQuestionReusePolicy.REUSE_MATCHING + } + private fun generateCategoryQuestions( owner: com.cw.vlainter.domain.user.entity.User, request: StartTechInterviewRequest, diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsServiceTests.kt new file mode 100644 index 0000000..8072a18 --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsServiceTests.kt @@ -0,0 +1,78 @@ +@file:Suppress("NonAsciiCharacters") + +package com.cw.vlainter.domain.interview.service + +import com.cw.vlainter.domain.interview.dto.UpdateAdminInterviewSettingsRequest +import com.cw.vlainter.domain.interview.entity.TechQuestionReusePolicy +import com.cw.vlainter.domain.interview.repository.AdminInterviewSettingRepository +import com.cw.vlainter.domain.user.entity.UserRole +import com.cw.vlainter.global.security.AuthPrincipal +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers +import org.mockito.BDDMockito.given +import org.mockito.BDDMockito.then +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.web.server.ResponseStatusException +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class AdminInterviewSettingsServiceTests { + + @Mock + private lateinit var adminInterviewSettingRepository: AdminInterviewSettingRepository + + @Test + fun `저장값이 없으면 기본 정책은 ALWAYS_GENERATE를 반환한다`() { + given(adminInterviewSettingRepository.findById(AdminInterviewSettingsService.TECH_QUESTION_REUSE_POLICY_KEY)) + .willReturn(Optional.empty()) + + val result = service().getSettings(adminPrincipal()) + + assertThat(result.techQuestionReusePolicy).isEqualTo(TechQuestionReusePolicy.ALWAYS_GENERATE) + assertThat(result.updatedAt).isNull() + } + + @Test + fun `관리자는 질문 재사용 정책을 변경할 수 있다`() { + given(adminInterviewSettingRepository.findById(AdminInterviewSettingsService.TECH_QUESTION_REUSE_POLICY_KEY)) + .willReturn(Optional.empty()) + given(adminInterviewSettingRepository.save(anyNonNull())) + .willAnswer { it.getArgument(0) } + + val result = service().updateSettings( + adminPrincipal(), + UpdateAdminInterviewSettingsRequest(techQuestionReusePolicy = TechQuestionReusePolicy.REUSE_MATCHING) + ) + + assertThat(result.techQuestionReusePolicy).isEqualTo(TechQuestionReusePolicy.REUSE_MATCHING) + then(adminInterviewSettingRepository).should().save(anyNonNull()) + } + + @Test + fun `관리자가 아니면 설정을 변경할 수 없다`() { + val ex = assertThrows(ResponseStatusException::class.java) { + service().updateSettings( + userPrincipal(), + UpdateAdminInterviewSettingsRequest(techQuestionReusePolicy = TechQuestionReusePolicy.REUSE_MATCHING) + ) + } + + assertThat(ex.statusCode.value()).isEqualTo(403) + } + + private fun service(): AdminInterviewSettingsService = AdminInterviewSettingsService(adminInterviewSettingRepository) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + ArgumentMatchers.any() + return null as T + } + + private fun adminPrincipal() = AuthPrincipal(1L, "admin@vlainter.com", "S", UserRole.ADMIN) + + private fun userPrincipal() = AuthPrincipal(2L, "user@vlainter.com", "S", UserRole.USER) +} diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt new file mode 100644 index 0000000..7524c00 --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt @@ -0,0 +1,365 @@ +@file:Suppress("NonAsciiCharacters") + +package com.cw.vlainter.domain.interview.service + +import com.cw.vlainter.domain.interview.ai.AiRoutingContextHolder +import com.cw.vlainter.domain.interview.ai.GeneratedTechQuestion +import com.cw.vlainter.domain.interview.ai.InterviewAiOrchestrator +import com.cw.vlainter.domain.interview.dto.StartTechInterviewRequest +import com.cw.vlainter.domain.interview.entity.InterviewLanguage +import com.cw.vlainter.domain.interview.entity.InterviewSession +import com.cw.vlainter.domain.interview.entity.InterviewTurn +import com.cw.vlainter.domain.interview.entity.QaCategory +import com.cw.vlainter.domain.interview.entity.QaQuestion +import com.cw.vlainter.domain.interview.entity.QaQuestionSet +import com.cw.vlainter.domain.interview.entity.QaQuestionSetItem +import com.cw.vlainter.domain.interview.entity.QuestionDifficulty +import com.cw.vlainter.domain.interview.entity.QuestionSetOwnerType +import com.cw.vlainter.domain.interview.entity.QuestionSetVisibility +import com.cw.vlainter.domain.interview.entity.QuestionSourceTag +import com.cw.vlainter.domain.interview.entity.TechQuestionReusePolicy +import com.cw.vlainter.domain.interview.repository.DocumentQuestionRepository +import com.cw.vlainter.domain.interview.repository.InterviewSessionRepository +import com.cw.vlainter.domain.interview.repository.InterviewTurnEvaluationRepository +import com.cw.vlainter.domain.interview.repository.InterviewTurnRepository +import com.cw.vlainter.domain.interview.repository.QaCategoryRepository +import com.cw.vlainter.domain.interview.repository.QaQuestionRepository +import com.cw.vlainter.domain.interview.repository.QaQuestionSetItemRepository +import com.cw.vlainter.domain.interview.repository.QaQuestionSetRepository +import com.cw.vlainter.domain.interview.repository.SavedQuestionRepository +import com.cw.vlainter.domain.interview.repository.UserQuestionAttemptRepository +import com.cw.vlainter.domain.user.entity.User +import com.cw.vlainter.domain.user.entity.UserRole +import com.cw.vlainter.domain.user.entity.UserStatus +import com.cw.vlainter.domain.user.repository.UserRepository +import com.cw.vlainter.domain.user.service.UserGeminiApiKeyService +import com.cw.vlainter.global.security.AuthPrincipal +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.BDDMockito.given +import org.mockito.BDDMockito.then +import org.mockito.Mockito.never +import org.mockito.Mockito.doAnswer +import org.mockito.junit.jupiter.MockitoExtension +import jakarta.persistence.EntityManager +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class InterviewPracticeServiceTests { + + @org.mockito.Mock private lateinit var interviewAiOrchestrator: InterviewAiOrchestrator + @org.mockito.Mock private lateinit var interviewEvaluationService: InterviewEvaluationService + @org.mockito.Mock private lateinit var jobSkillCatalogService: JobSkillCatalogService + @org.mockito.Mock private lateinit var categoryRepository: QaCategoryRepository + @org.mockito.Mock private lateinit var categoryContextResolver: InterviewCategoryContextResolver + @org.mockito.Mock private lateinit var questionRepository: QaQuestionRepository + @org.mockito.Mock private lateinit var questionSetRepository: QaQuestionSetRepository + @org.mockito.Mock private lateinit var questionSetItemRepository: QaQuestionSetItemRepository + @org.mockito.Mock private lateinit var interviewSessionRepository: InterviewSessionRepository + @org.mockito.Mock private lateinit var interviewTurnRepository: InterviewTurnRepository + @org.mockito.Mock private lateinit var interviewTurnEvaluationRepository: InterviewTurnEvaluationRepository + @org.mockito.Mock private lateinit var documentQuestionRepository: DocumentQuestionRepository + @org.mockito.Mock private lateinit var userQuestionAttemptRepository: UserQuestionAttemptRepository + @org.mockito.Mock private lateinit var savedQuestionRepository: SavedQuestionRepository + @org.mockito.Mock private lateinit var userRepository: UserRepository + @org.mockito.Mock private lateinit var userGeminiApiKeyService: UserGeminiApiKeyService + @org.mockito.Mock private lateinit var adminInterviewSettingsService: AdminInterviewSettingsService + @org.mockito.Mock private lateinit var entityManager: EntityManager + + private val objectMapper = ObjectMapper() + private val aiRoutingContextHolder = AiRoutingContextHolder() + + @Test + fun `같은 조건 질문 재사용 정책이면 기존 후보를 사용한다`() { + val user = createUser() + val category = createCategory() + val question = createQuestion(id = 101L, category = category) + val request = StartTechInterviewRequest( + categoryId = category.id, + difficulty = QuestionDifficulty.MEDIUM, + questionCount = 1 + ) + + given(userRepository.findById(1L)).willReturn(Optional.of(user)) + given(adminInterviewSettingsService.getTechQuestionReusePolicy()).willReturn(TechQuestionReusePolicy.REUSE_MATCHING) + given(categoryRepository.findByIdAndDeletedAtIsNull(category.id)).willReturn(category) + given(categoryRepository.findAllByPathStartingWithAndDeletedAtIsNullAndIsActiveTrueOrderByDepthAscSortOrderAsc("${category.path}/")) + .willReturn(emptyList()) + given( + questionRepository.findCandidatesForUser( + user.id, + com.cw.vlainter.domain.interview.entity.QuestionSetStatus.ACTIVE, + QuestionSetVisibility.GLOBAL, + request.difficulty, + request.sourceTag + ) + ).willReturn(listOf(question)) + given(questionRepository.findByIdAndDeletedAtIsNull(101L)).willReturn(question) + given(interviewSessionRepository.save(any(InterviewSession::class.java))).willAnswer { invocation -> + val source = invocation.getArgument(0) + InterviewSession( + id = 501L, + user = source.user, + mode = source.mode, + status = source.status, + questionSet = source.questionSet, + revealPolicy = source.revealPolicy, + configJson = source.configJson + ) + } + given(interviewTurnRepository.save(any(InterviewTurn::class.java))).willAnswer { invocation -> + val source = invocation.getArgument(0) + InterviewTurn( + id = 901L, + session = source.session, + turnNo = source.turnNo, + sourceTag = source.sourceTag, + question = source.question, + documentQuestion = source.documentQuestion, + questionTextSnapshot = source.questionTextSnapshot, + categorySnapshot = source.categorySnapshot, + jobSnapshot = source.jobSnapshot, + skillSnapshot = source.skillSnapshot, + category = source.category, + difficulty = source.difficulty, + tagsJson = source.tagsJson, + ragContextJson = source.ragContextJson + ) + } + given(questionSetItemRepository.existsInAiGeneratedSetByQuestionId(question.id)).willReturn(false) + + val result = service().startTechInterview( + principal = AuthPrincipal(1L, "user@vlainter.com", "S", UserRole.USER), + request = request + ) + + assertThat(result.currentQuestion.questionId).isEqualTo(question.id) + then(questionRepository).should().findCandidatesForUser( + user.id, + com.cw.vlainter.domain.interview.entity.QuestionSetStatus.ACTIVE, + QuestionSetVisibility.GLOBAL, + request.difficulty, + request.sourceTag + ) + then(interviewAiOrchestrator).should(never()).generateTechQuestions( + "백엔드개발자", + "Spring", + QuestionDifficulty.MEDIUM, + 5, + InterviewLanguage.KO + ) + } + + @Test + fun `무조건 생성 정책이면 기존 후보가 있어도 새 질문 생성을 시도한다`() { + val user = createUser() + val branch = createCategory(id = 11L, name = "개발", depth = 0, path = "/dev") + val job = createCategory(id = 12L, name = "백엔드개발자", depth = 1, path = "/dev/backend", parent = branch) + val skill = createCategory(id = 13L, name = "Spring", depth = 2, path = "/dev/backend/spring", parent = job) + val generatedQuestion = createQuestion(id = 202L, category = skill, text = "Spring Bean 생명주기를 설명해 주세요.") + + given(userRepository.findById(1L)).willReturn(Optional.of(user)) + given(adminInterviewSettingsService.getTechQuestionReusePolicy()).willReturn(TechQuestionReusePolicy.ALWAYS_GENERATE) + given( + categoryContextResolver.resolve( + categoryId = skill.id, + jobName = null, + skillName = null, + requireIfMissing = false + ) + ).willReturn( + InterviewCategoryContextResolver.ResolvedCategoryContext( + category = skill, + branchName = "개발", + jobName = "백엔드개발자", + skillName = "Spring" + ) + ) + given( + interviewAiOrchestrator.generateTechQuestions( + jobName = "백엔드개발자", + skillName = "Spring", + difficulty = QuestionDifficulty.MEDIUM, + questionCount = 5, + language = InterviewLanguage.KO + ) + ).willReturn( + listOf( + GeneratedTechQuestion( + "Spring Bean 생명주기를 설명해 주세요.", + "컨테이너 초기화부터 destroy까지 답합니다.", + listOf("Spring") + ) + ) + ) + doAnswer { invocation -> + val block = invocation.getArgument<() -> List>(1) + block() + }.`when`(userGeminiApiKeyService).withUserApiKey( + eq(1L), + anyNonNull<() -> List>() + ) + given( + questionSetRepository.findFirstByOwnerUser_IdAndOwnerTypeAndVisibilityAndJobNameAndSkillNameAndDescriptionAndDeletedAtIsNullOrderByCreatedAtDesc( + user.id, + QuestionSetOwnerType.USER, + QuestionSetVisibility.PRIVATE, + "백엔드개발자", + "Spring", + "카테고리 기반 자동 생성 문답" + ) + ) + .willReturn(null) + given(questionSetRepository.save(anyNonNull())).willAnswer { invocation -> + val source = invocation.getArgument(0) + QaQuestionSet( + id = 301L, + ownerUser = source.ownerUser, + ownerType = source.ownerType, + title = source.title, + jobName = source.jobName, + skillName = source.skillName, + description = source.description, + visibility = source.visibility, + status = source.status + ) + } + given(questionRepository.findByFingerprintAndDeletedAtIsNull(anyNonNull())).willReturn(generatedQuestion) + given(questionRepository.findByIdAndDeletedAtIsNull(202L)).willReturn(generatedQuestion) + given(questionSetItemRepository.existsBySet_IdAndQuestion_Id(301L, 202L)).willReturn(false) + given(questionSetItemRepository.findMaxOrderNo(301L)).willReturn(0) + given(questionSetItemRepository.save(anyNonNull())).willAnswer { it.getArgument(0) } + given(interviewSessionRepository.save(anyNonNull())).willAnswer { invocation -> + val source = invocation.getArgument(0) + InterviewSession( + id = 502L, + user = source.user, + mode = source.mode, + status = source.status, + questionSet = source.questionSet, + revealPolicy = source.revealPolicy, + configJson = source.configJson + ) + } + given(interviewTurnRepository.save(anyNonNull())).willAnswer { invocation -> + val source = invocation.getArgument(0) + InterviewTurn( + id = 902L, + session = source.session, + turnNo = source.turnNo, + sourceTag = source.sourceTag, + question = source.question, + documentQuestion = source.documentQuestion, + questionTextSnapshot = source.questionTextSnapshot, + categorySnapshot = source.categorySnapshot, + jobSnapshot = source.jobSnapshot, + skillSnapshot = source.skillSnapshot, + category = source.category, + difficulty = source.difficulty, + tagsJson = source.tagsJson, + ragContextJson = source.ragContextJson + ) + } + given(questionSetItemRepository.existsInAiGeneratedSetByQuestionId(generatedQuestion.id)).willReturn(true) + + val result = service().startTechInterview( + principal = AuthPrincipal(1L, "user@vlainter.com", "S", UserRole.USER), + request = StartTechInterviewRequest( + categoryId = skill.id, + difficulty = QuestionDifficulty.MEDIUM, + questionCount = 1 + ) + ) + + assertThat(result.currentQuestion.questionId).isEqualTo(generatedQuestion.id) + then(questionRepository).should(never()).findCandidatesForUser( + user.id, + com.cw.vlainter.domain.interview.entity.QuestionSetStatus.ACTIVE, + QuestionSetVisibility.GLOBAL, + QuestionDifficulty.MEDIUM, + null + ) + then(interviewAiOrchestrator).should().generateTechQuestions( + "백엔드개발자", + "Spring", + QuestionDifficulty.MEDIUM, + 5, + InterviewLanguage.KO + ) + } + + private fun service() = InterviewPracticeService( + interviewAiOrchestrator = interviewAiOrchestrator, + aiRoutingContextHolder = aiRoutingContextHolder, + interviewEvaluationService = interviewEvaluationService, + jobSkillCatalogService = jobSkillCatalogService, + categoryRepository = categoryRepository, + categoryContextResolver = categoryContextResolver, + questionRepository = questionRepository, + questionSetRepository = questionSetRepository, + questionSetItemRepository = questionSetItemRepository, + interviewSessionRepository = interviewSessionRepository, + interviewTurnRepository = interviewTurnRepository, + interviewTurnEvaluationRepository = interviewTurnEvaluationRepository, + documentQuestionRepository = documentQuestionRepository, + userQuestionAttemptRepository = userQuestionAttemptRepository, + savedQuestionRepository = savedQuestionRepository, + userRepository = userRepository, + userGeminiApiKeyService = userGeminiApiKeyService, + adminInterviewSettingsService = adminInterviewSettingsService, + objectMapper = objectMapper, + entityManager = entityManager + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + any() + return null as T + } + + private fun createUser(id: Long = 1L) = User( + id = id, + email = "user@vlainter.com", + password = "encoded", + name = "테스터", + status = UserStatus.ACTIVE, + role = UserRole.USER, + geminiApiKeyEncrypted = "encrypted" + ) + + private fun createCategory( + id: Long = 13L, + name: String = "Spring", + depth: Int = 2, + path: String = "/dev/backend/spring", + parent: QaCategory? = null + ) = QaCategory( + id = id, + parent = parent, + code = name.lowercase(), + name = name, + depth = depth, + path = path + ) + + private fun createQuestion( + id: Long, + category: QaCategory, + text: String = "Spring DI를 설명해 주세요." + ) = QaQuestion( + id = id, + fingerprint = "fp-$id", + questionText = text, + canonicalAnswer = "의존성을 외부에서 주입하는 패턴입니다.", + category = category, + jobName = "백엔드개발자", + skillName = category.name, + difficulty = QuestionDifficulty.MEDIUM, + sourceTag = QuestionSourceTag.SYSTEM, + tagsJson = "[]" + ) +} From ac381fe44650146fcae3e5e19eef0594c0485883 Mon Sep 17 00:00:00 2001 From: rktclgh Date: Sat, 14 Mar 2026 04:13:58 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=8C=A8=EC=B9=98=EB=85=B8=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminPatchNoteController.kt | 56 ++++++++ .../site/controller/PatchNoteController.kt | 19 +++ .../vlainter/domain/site/dto/PatchNoteDtos.kt | 34 +++++ .../vlainter/domain/site/entity/PatchNote.kt | 51 +++++++ .../site/repository/PatchNoteRepository.kt | 9 ++ .../domain/site/service/PatchNoteService.kt | 97 +++++++++++++ .../vlainter/global/config/SecurityConfig.kt | 1 + .../site/service/PatchNoteServiceTests.kt | 131 ++++++++++++++++++ 8 files changed, 398 insertions(+) create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/controller/PatchNoteController.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/entity/PatchNote.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt create mode 100644 src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt new file mode 100644 index 0000000..ec94e17 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt @@ -0,0 +1,56 @@ +package com.cw.vlainter.domain.site.controller + +import com.cw.vlainter.domain.site.dto.AdminPatchNoteResponse +import com.cw.vlainter.domain.site.dto.CreatePatchNoteRequest +import com.cw.vlainter.domain.site.dto.UpdatePatchNoteRequest +import com.cw.vlainter.domain.site.service.PatchNoteService +import com.cw.vlainter.global.security.AuthPrincipal +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PatchMapping +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 + +@RestController +@RequestMapping("/api/admin/site/patch-notes") +class AdminPatchNoteController( + private val patchNoteService: PatchNoteService +) { + @GetMapping + fun getPatchNotes( + @AuthenticationPrincipal principal: AuthPrincipal + ): ResponseEntity> { + return ResponseEntity.ok(patchNoteService.getAdminPatchNotes(principal)) + } + + @PostMapping + fun createPatchNote( + @AuthenticationPrincipal principal: AuthPrincipal, + @RequestBody request: CreatePatchNoteRequest + ): ResponseEntity { + return ResponseEntity.ok(patchNoteService.createPatchNote(principal, request)) + } + + @PatchMapping("/{patchNoteId}") + fun updatePatchNote( + @AuthenticationPrincipal principal: AuthPrincipal, + @PathVariable patchNoteId: Long, + @RequestBody request: UpdatePatchNoteRequest + ): ResponseEntity { + return ResponseEntity.ok(patchNoteService.updatePatchNote(principal, patchNoteId, request)) + } + + @DeleteMapping("/{patchNoteId}") + fun deletePatchNote( + @AuthenticationPrincipal principal: AuthPrincipal, + @PathVariable patchNoteId: Long + ): ResponseEntity> { + patchNoteService.deletePatchNote(principal, patchNoteId) + return ResponseEntity.ok(mapOf("message" to "패치노트가 삭제되었습니다.")) + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/controller/PatchNoteController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/PatchNoteController.kt new file mode 100644 index 0000000..9c1da99 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/PatchNoteController.kt @@ -0,0 +1,19 @@ +package com.cw.vlainter.domain.site.controller + +import com.cw.vlainter.domain.site.dto.PublicPatchNoteResponse +import com.cw.vlainter.domain.site.service.PatchNoteService +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.RestController + +@RestController +@RequestMapping("/api/site/patch-notes") +class PatchNoteController( + private val patchNoteService: PatchNoteService +) { + @GetMapping + fun getPublishedPatchNotes(): ResponseEntity> { + return ResponseEntity.ok(patchNoteService.getPublishedPatchNotes()) + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt new file mode 100644 index 0000000..3f16e87 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt @@ -0,0 +1,34 @@ +package com.cw.vlainter.domain.site.dto + +import java.time.OffsetDateTime + +data class PublicPatchNoteResponse( + val patchNoteId: Long, + val title: String, + val body: String, + val sortOrder: Int +) + +data class AdminPatchNoteResponse( + val patchNoteId: Long, + val title: String, + val body: String, + val sortOrder: Int, + val isPublished: Boolean, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime +) + +data class CreatePatchNoteRequest( + val title: String, + val body: String, + val sortOrder: Int = 0, + val isPublished: Boolean = true +) + +data class UpdatePatchNoteRequest( + val title: String, + val body: String, + val sortOrder: Int = 0, + val isPublished: Boolean = true +) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/entity/PatchNote.kt b/src/main/kotlin/com/cw/vlainter/domain/site/entity/PatchNote.kt new file mode 100644 index 0000000..be48eda --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/entity/PatchNote.kt @@ -0,0 +1,51 @@ +@file:Suppress("JpaDataSourceORMInspection") + +package com.cw.vlainter.domain.site.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate +import jakarta.persistence.Table +import java.time.OffsetDateTime + +@Entity +@Table(name = "patch_notes") +class PatchNote( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @Column(name = "title", nullable = false, length = 160) + var title: String, + + @Column(name = "body", nullable = false, columnDefinition = "text") + var body: String, + + @Column(name = "sort_order", nullable = false) + var sortOrder: Int = 0, + + @Column(name = "is_published", nullable = false) + var isPublished: Boolean = true, + + @Column(name = "created_at", nullable = false) + var createdAt: OffsetDateTime = OffsetDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: OffsetDateTime = OffsetDateTime.now() +) { + @PrePersist + fun prePersist() { + val now = OffsetDateTime.now() + createdAt = now + updatedAt = now + } + + @PreUpdate + fun preUpdate() { + updatedAt = OffsetDateTime.now() + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt b/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt new file mode 100644 index 0000000..171c99d --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt @@ -0,0 +1,9 @@ +package com.cw.vlainter.domain.site.repository + +import com.cw.vlainter.domain.site.entity.PatchNote +import org.springframework.data.jpa.repository.JpaRepository + +interface PatchNoteRepository : JpaRepository { + fun findAllByIsPublishedTrueOrderBySortOrderAscCreatedAtDesc(): List + fun findAllByOrderBySortOrderAscCreatedAtDesc(): List +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt new file mode 100644 index 0000000..fff9308 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt @@ -0,0 +1,97 @@ +package com.cw.vlainter.domain.site.service + +import com.cw.vlainter.domain.site.dto.AdminPatchNoteResponse +import com.cw.vlainter.domain.site.dto.CreatePatchNoteRequest +import com.cw.vlainter.domain.site.dto.PublicPatchNoteResponse +import com.cw.vlainter.domain.site.dto.UpdatePatchNoteRequest +import com.cw.vlainter.domain.site.entity.PatchNote +import com.cw.vlainter.domain.site.repository.PatchNoteRepository +import com.cw.vlainter.domain.user.entity.UserRole +import com.cw.vlainter.global.security.AuthPrincipal +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException + +@Service +class PatchNoteService( + private val patchNoteRepository: PatchNoteRepository +) { + @Transactional(readOnly = true) + fun getPublishedPatchNotes(): List { + return patchNoteRepository.findAllByIsPublishedTrueOrderBySortOrderAscCreatedAtDesc() + .map { it.toPublicResponse() } + } + + @Transactional(readOnly = true) + fun getAdminPatchNotes(principal: AuthPrincipal): List { + ensureAdmin(principal) + return patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc() + .map { it.toAdminResponse() } + } + + @Transactional + fun createPatchNote(principal: AuthPrincipal, request: CreatePatchNoteRequest): AdminPatchNoteResponse { + ensureAdmin(principal) + val saved = patchNoteRepository.save( + PatchNote( + title = request.title.trim().ifBlank { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "패치노트 제목을 입력해 주세요.") + }, + body = request.body.trim().ifBlank { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "패치노트 본문을 입력해 주세요.") + }, + sortOrder = request.sortOrder, + isPublished = request.isPublished + ) + ) + return saved.toAdminResponse() + } + + @Transactional + fun updatePatchNote(principal: AuthPrincipal, patchNoteId: Long, request: UpdatePatchNoteRequest): AdminPatchNoteResponse { + ensureAdmin(principal) + val patchNote = patchNoteRepository.findById(patchNoteId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "패치노트를 찾을 수 없습니다.") } + patchNote.title = request.title.trim().ifBlank { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "패치노트 제목을 입력해 주세요.") + } + patchNote.body = request.body.trim().ifBlank { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "패치노트 본문을 입력해 주세요.") + } + patchNote.sortOrder = request.sortOrder + patchNote.isPublished = request.isPublished + return patchNoteRepository.save(patchNote).toAdminResponse() + } + + @Transactional + fun deletePatchNote(principal: AuthPrincipal, patchNoteId: Long) { + ensureAdmin(principal) + val patchNote = patchNoteRepository.findById(patchNoteId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "패치노트를 찾을 수 없습니다.") } + patchNoteRepository.delete(patchNote) + } + + private fun ensureAdmin(principal: AuthPrincipal) { + if (principal.role != UserRole.ADMIN) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "관리자만 접근할 수 있습니다.") + } + } + + private fun PatchNote.toPublicResponse(): PublicPatchNoteResponse = PublicPatchNoteResponse( + patchNoteId = id, + title = title, + body = body, + sortOrder = sortOrder + ) + + private fun PatchNote.toAdminResponse(): AdminPatchNoteResponse = AdminPatchNoteResponse( + patchNoteId = id, + title = title, + body = body, + sortOrder = sortOrder, + isPublished = isPublished, + createdAt = createdAt, + updatedAt = updatedAt + ) +} diff --git a/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt b/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt index 503123e..b65850f 100644 --- a/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt @@ -64,6 +64,7 @@ class SecurityConfig( .authorizeHttpRequests { var registry = it.requestMatchers(*PUBLIC_API_PATHS).permitAll() registry = registry.requestMatchers("/actuator/health").permitAll() + registry = registry.requestMatchers(HttpMethod.GET, "/api/site/patch-notes").permitAll() if (docsEnabled) { registry = registry.requestMatchers(*PUBLIC_DOCS_PATHS).permitAll() } diff --git a/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt new file mode 100644 index 0000000..d95e6f1 --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt @@ -0,0 +1,131 @@ +@file:Suppress("NonAsciiCharacters") + +package com.cw.vlainter.domain.site.service + +import com.cw.vlainter.domain.site.dto.CreatePatchNoteRequest +import com.cw.vlainter.domain.site.dto.UpdatePatchNoteRequest +import com.cw.vlainter.domain.site.entity.PatchNote +import com.cw.vlainter.domain.site.repository.PatchNoteRepository +import com.cw.vlainter.domain.user.entity.UserRole +import com.cw.vlainter.global.security.AuthPrincipal +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers +import org.mockito.BDDMockito.given +import org.mockito.BDDMockito.then +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.web.server.ResponseStatusException +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class PatchNoteServiceTests { + + @Mock + private lateinit var patchNoteRepository: PatchNoteRepository + + @Test + fun `공개 패치노트는 published 항목만 반환한다`() { + given(patchNoteRepository.findAllByIsPublishedTrueOrderBySortOrderAscCreatedAtDesc()) + .willReturn( + listOf( + PatchNote(id = 1L, title = "Landing Refresh", body = "body", sortOrder = 0, isPublished = true), + PatchNote(id = 2L, title = "Security", body = "body2", sortOrder = 1, isPublished = true) + ) + ) + + val result = service().getPublishedPatchNotes() + + assertThat(result).hasSize(2) + assertThat(result.map { it.title }).containsExactly("Landing Refresh", "Security") + } + + @Test + fun `관리자는 패치노트를 생성할 수 있다`() { + given(patchNoteRepository.save(anyNonNull())) + .willAnswer { invocation -> + val note = invocation.getArgument(0) + PatchNote( + id = 10L, + title = note.title, + body = note.body, + sortOrder = note.sortOrder, + isPublished = note.isPublished + ) + } + + val result = service().createPatchNote( + adminPrincipal(), + CreatePatchNoteRequest(title = " Landing Refresh ", body = " body ", sortOrder = 2, isPublished = false) + ) + + assertThat(result.patchNoteId).isEqualTo(10L) + assertThat(result.title).isEqualTo("Landing Refresh") + assertThat(result.body).isEqualTo("body") + assertThat(result.sortOrder).isEqualTo(2) + assertThat(result.isPublished).isFalse() + } + + @Test + fun `관리자는 패치노트를 수정할 수 있다`() { + val existing = PatchNote( + id = 3L, + title = "Old", + body = "Old body", + sortOrder = 0, + isPublished = true + ) + given(patchNoteRepository.findById(3L)).willReturn(Optional.of(existing)) + given(patchNoteRepository.save(existing)).willReturn(existing) + + val result = service().updatePatchNote( + adminPrincipal(), + 3L, + UpdatePatchNoteRequest(title = " New ", body = " New body ", sortOrder = 5, isPublished = false) + ) + + assertThat(result.title).isEqualTo("New") + assertThat(result.body).isEqualTo("New body") + assertThat(result.sortOrder).isEqualTo(5) + assertThat(result.isPublished).isFalse() + } + + @Test + fun `관리자가 아니면 패치노트를 삭제할 수 없다`() { + val exception = assertThrows(ResponseStatusException::class.java) { + service().deletePatchNote(userPrincipal(), 1L) + } + + assertThat(exception.statusCode.value()).isEqualTo(403) + } + + @Test + fun `관리자는 패치노트를 삭제할 수 있다`() { + val existing = PatchNote( + id = 4L, + title = "Delete me", + body = "body", + sortOrder = 0, + isPublished = true + ) + given(patchNoteRepository.findById(4L)).willReturn(Optional.of(existing)) + + service().deletePatchNote(adminPrincipal(), 4L) + + then(patchNoteRepository).should().delete(existing) + } + + private fun service(): PatchNoteService = PatchNoteService(patchNoteRepository) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + ArgumentMatchers.any() + return null as T + } + + private fun adminPrincipal() = AuthPrincipal(1L, "admin@vlainter.com", "S", UserRole.ADMIN) + + private fun userPrincipal() = AuthPrincipal(2L, "user@vlainter.com", "S", UserRole.USER) +} From 38bd5c8314097ea9be5501e4bd4f92aa38721962 Mon Sep 17 00:00:00 2001 From: rktclgh Date: Sat, 14 Mar 2026 04:30:46 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=9A=B4=EC=98=81=EC=9E=90=20=ED=8C=A8?= =?UTF-8?q?=EC=B9=98=EB=85=B8=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminPatchNoteController.kt | 9 +++ .../controller/AdminSiteSettingsController.kt | 34 +++++++++ .../site/controller/SiteSettingsController.kt | 19 +++++ .../vlainter/domain/site/dto/PatchNoteDtos.kt | 8 +- .../domain/site/dto/SiteSettingsDtos.kt | 16 ++++ .../domain/site/service/PatchNoteService.kt | 29 ++++++- .../site/service/SiteSettingsService.kt | 76 +++++++++++++++++++ .../vlainter/global/config/SecurityConfig.kt | 1 + 8 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/controller/SiteSettingsController.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt index ec94e17..64c001b 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt @@ -2,6 +2,7 @@ package com.cw.vlainter.domain.site.controller import com.cw.vlainter.domain.site.dto.AdminPatchNoteResponse import com.cw.vlainter.domain.site.dto.CreatePatchNoteRequest +import com.cw.vlainter.domain.site.dto.ReorderPatchNotesRequest import com.cw.vlainter.domain.site.dto.UpdatePatchNoteRequest import com.cw.vlainter.domain.site.service.PatchNoteService import com.cw.vlainter.global.security.AuthPrincipal @@ -45,6 +46,14 @@ class AdminPatchNoteController( return ResponseEntity.ok(patchNoteService.updatePatchNote(principal, patchNoteId, request)) } + @PatchMapping("/reorder") + fun reorderPatchNotes( + @AuthenticationPrincipal principal: AuthPrincipal, + @RequestBody request: ReorderPatchNotesRequest + ): ResponseEntity> { + return ResponseEntity.ok(patchNoteService.reorderPatchNotes(principal, request)) + } + @DeleteMapping("/{patchNoteId}") fun deletePatchNote( @AuthenticationPrincipal principal: AuthPrincipal, diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt new file mode 100644 index 0000000..cfbc826 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt @@ -0,0 +1,34 @@ +package com.cw.vlainter.domain.site.controller + +import com.cw.vlainter.domain.site.dto.AdminSiteSettingsResponse +import com.cw.vlainter.domain.site.dto.UpdateAdminSiteSettingsRequest +import com.cw.vlainter.domain.site.service.SiteSettingsService +import com.cw.vlainter.global.security.AuthPrincipal +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/admin/site/settings") +class AdminSiteSettingsController( + private val siteSettingsService: SiteSettingsService +) { + @GetMapping + fun getAdminSettings( + @AuthenticationPrincipal principal: AuthPrincipal + ): ResponseEntity { + return ResponseEntity.ok(siteSettingsService.getAdminSettings(principal)) + } + + @PatchMapping + fun updateAdminSettings( + @AuthenticationPrincipal principal: AuthPrincipal, + @RequestBody request: UpdateAdminSiteSettingsRequest + ): ResponseEntity { + return ResponseEntity.ok(siteSettingsService.updateAdminSettings(principal, request)) + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/controller/SiteSettingsController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/SiteSettingsController.kt new file mode 100644 index 0000000..fe51a8e --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/SiteSettingsController.kt @@ -0,0 +1,19 @@ +package com.cw.vlainter.domain.site.controller + +import com.cw.vlainter.domain.site.dto.PublicSiteSettingsResponse +import com.cw.vlainter.domain.site.service.SiteSettingsService +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.RestController + +@RestController +@RequestMapping("/api/site/settings") +class SiteSettingsController( + private val siteSettingsService: SiteSettingsService +) { + @GetMapping + fun getPublicSettings(): ResponseEntity { + return ResponseEntity.ok(siteSettingsService.getPublicSettings()) + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt index 3f16e87..3266dc3 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt @@ -22,13 +22,17 @@ data class AdminPatchNoteResponse( data class CreatePatchNoteRequest( val title: String, val body: String, - val sortOrder: Int = 0, + val sortOrder: Int? = null, val isPublished: Boolean = true ) data class UpdatePatchNoteRequest( val title: String, val body: String, - val sortOrder: Int = 0, + val sortOrder: Int? = null, val isPublished: Boolean = true ) + +data class ReorderPatchNotesRequest( + val patchNoteIds: List +) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt new file mode 100644 index 0000000..fffa629 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt @@ -0,0 +1,16 @@ +package com.cw.vlainter.domain.site.dto + +import java.time.OffsetDateTime + +data class PublicSiteSettingsResponse( + val landingVersionLabel: String +) + +data class AdminSiteSettingsResponse( + val landingVersionLabel: String, + val updatedAt: OffsetDateTime? +) + +data class UpdateAdminSiteSettingsRequest( + val landingVersionLabel: String +) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt index fff9308..b0d06cb 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt @@ -3,6 +3,7 @@ package com.cw.vlainter.domain.site.service import com.cw.vlainter.domain.site.dto.AdminPatchNoteResponse import com.cw.vlainter.domain.site.dto.CreatePatchNoteRequest import com.cw.vlainter.domain.site.dto.PublicPatchNoteResponse +import com.cw.vlainter.domain.site.dto.ReorderPatchNotesRequest import com.cw.vlainter.domain.site.dto.UpdatePatchNoteRequest import com.cw.vlainter.domain.site.entity.PatchNote import com.cw.vlainter.domain.site.repository.PatchNoteRepository @@ -33,6 +34,7 @@ class PatchNoteService( @Transactional fun createPatchNote(principal: AuthPrincipal, request: CreatePatchNoteRequest): AdminPatchNoteResponse { ensureAdmin(principal) + val nextSortOrder = request.sortOrder ?: ((patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc().firstOrNull()?.sortOrder ?: 0) - 10) val saved = patchNoteRepository.save( PatchNote( title = request.title.trim().ifBlank { @@ -41,7 +43,7 @@ class PatchNoteService( body = request.body.trim().ifBlank { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "패치노트 본문을 입력해 주세요.") }, - sortOrder = request.sortOrder, + sortOrder = nextSortOrder, isPublished = request.isPublished ) ) @@ -59,11 +61,34 @@ class PatchNoteService( patchNote.body = request.body.trim().ifBlank { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "패치노트 본문을 입력해 주세요.") } - patchNote.sortOrder = request.sortOrder + request.sortOrder?.let { patchNote.sortOrder = it } patchNote.isPublished = request.isPublished return patchNoteRepository.save(patchNote).toAdminResponse() } + @Transactional + fun reorderPatchNotes(principal: AuthPrincipal, request: ReorderPatchNotesRequest): List { + ensureAdmin(principal) + val requestedIds = request.patchNoteIds.distinct() + if (requestedIds.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "정렬할 패치노트가 없습니다.") + } + val allPatchNotes = patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc() + val allIds = allPatchNotes.map { it.id } + if (!allIds.containsAll(requestedIds)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "일부 패치노트를 찾을 수 없습니다.") + } + val remainingIds = allIds.filterNot { requestedIds.contains(it) } + val orderedIds = requestedIds + remainingIds + val patchNoteById = allPatchNotes.associateBy { it.id } + orderedIds.forEachIndexed { index, patchNoteId -> + patchNoteById[patchNoteId]?.sortOrder = index * 10 + } + patchNoteRepository.saveAll(allPatchNotes) + return patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc() + .map { it.toAdminResponse() } + } + @Transactional fun deletePatchNote(principal: AuthPrincipal, patchNoteId: Long) { ensureAdmin(principal) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt new file mode 100644 index 0000000..bce3bb9 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt @@ -0,0 +1,76 @@ +package com.cw.vlainter.domain.site.service + +import com.cw.vlainter.domain.interview.entity.AdminInterviewSetting +import com.cw.vlainter.domain.interview.repository.AdminInterviewSettingRepository +import com.cw.vlainter.domain.site.dto.AdminSiteSettingsResponse +import com.cw.vlainter.domain.site.dto.PublicSiteSettingsResponse +import com.cw.vlainter.domain.site.dto.UpdateAdminSiteSettingsRequest +import com.cw.vlainter.domain.user.entity.UserRole +import com.cw.vlainter.global.security.AuthPrincipal +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException + +@Service +class SiteSettingsService( + private val adminInterviewSettingRepository: AdminInterviewSettingRepository +) { + @Transactional(readOnly = true) + fun getPublicSettings(): PublicSiteSettingsResponse { + return PublicSiteSettingsResponse( + landingVersionLabel = getLandingVersionLabel() + ) + } + + @Transactional(readOnly = true) + fun getAdminSettings(principal: AuthPrincipal): AdminSiteSettingsResponse { + ensureAdmin(principal) + val saved = adminInterviewSettingRepository.findById(LANDING_VERSION_LABEL_KEY).orElse(null) + return AdminSiteSettingsResponse( + landingVersionLabel = saved?.settingValue ?: DEFAULT_LANDING_VERSION_LABEL, + updatedAt = saved?.updatedAt + ) + } + + @Transactional + fun updateAdminSettings( + principal: AuthPrincipal, + request: UpdateAdminSiteSettingsRequest + ): AdminSiteSettingsResponse { + ensureAdmin(principal) + val normalizedLabel = request.landingVersionLabel.trim().ifBlank { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "버전 라벨을 입력해 주세요.") + } + val saved = adminInterviewSettingRepository.findById(LANDING_VERSION_LABEL_KEY) + .orElseGet { + AdminInterviewSetting( + settingKey = LANDING_VERSION_LABEL_KEY, + settingValue = normalizedLabel + ) + } + saved.settingValue = normalizedLabel + val updated = adminInterviewSettingRepository.save(saved) + return AdminSiteSettingsResponse( + landingVersionLabel = updated.settingValue, + updatedAt = updated.updatedAt + ) + } + + @Transactional(readOnly = true) + fun getLandingVersionLabel(): String { + val saved = adminInterviewSettingRepository.findById(LANDING_VERSION_LABEL_KEY).orElse(null) + return saved?.settingValue ?: DEFAULT_LANDING_VERSION_LABEL + } + + private fun ensureAdmin(principal: AuthPrincipal) { + if (principal.role != UserRole.ADMIN) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "관리자만 접근할 수 있습니다.") + } + } + + companion object { + const val LANDING_VERSION_LABEL_KEY = "landing_version_label" + const val DEFAULT_LANDING_VERSION_LABEL = "v0.4" + } +} diff --git a/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt b/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt index b65850f..b1e7f9c 100644 --- a/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt @@ -65,6 +65,7 @@ class SecurityConfig( var registry = it.requestMatchers(*PUBLIC_API_PATHS).permitAll() registry = registry.requestMatchers("/actuator/health").permitAll() registry = registry.requestMatchers(HttpMethod.GET, "/api/site/patch-notes").permitAll() + registry = registry.requestMatchers(HttpMethod.GET, "/api/site/settings").permitAll() if (docsEnabled) { registry = registry.requestMatchers(*PUBLIC_DOCS_PATHS).permitAll() } From d11a50d00c4d9002c9100520811ab549549c817a Mon Sep 17 00:00:00 2001 From: rktclgh Date: Sat, 14 Mar 2026 11:15:40 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminInterviewSettingsService.kt | 18 ++++- .../service/InterviewPracticeService.kt | 7 ++ .../controller/AdminPatchNoteController.kt | 7 +- .../controller/AdminSiteSettingsController.kt | 3 +- .../vlainter/domain/site/dto/PatchNoteDtos.kt | 12 +++ .../domain/site/dto/SiteSettingsDtos.kt | 2 + .../domain/site/service/PatchNoteService.kt | 5 +- .../site/service/SiteSettingsService.kt | 7 +- .../service/InterviewPracticeServiceTests.kt | 75 +++++++++++++++++-- .../site/service/PatchNoteServiceTests.kt | 13 ++++ 10 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt index 2f6555d..5cb1a4e 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt @@ -7,6 +7,7 @@ import com.cw.vlainter.domain.interview.entity.TechQuestionReusePolicy import com.cw.vlainter.domain.interview.repository.AdminInterviewSettingRepository import com.cw.vlainter.domain.user.entity.UserRole import com.cw.vlainter.global.security.AuthPrincipal +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,6 +17,8 @@ import org.springframework.web.server.ResponseStatusException class AdminInterviewSettingsService( private val adminInterviewSettingRepository: AdminInterviewSettingRepository ) { + private val logger = LoggerFactory.getLogger(javaClass) + @Transactional(readOnly = true) fun getSettings(principal: AuthPrincipal): AdminInterviewSettingsResponse { ensureAdmin(principal) @@ -33,13 +36,16 @@ class AdminInterviewSettingsService( ): AdminInterviewSettingsResponse { ensureAdmin(principal) val saved = adminInterviewSettingRepository.findById(TECH_QUESTION_REUSE_POLICY_KEY) + .map { + it.settingValue = request.techQuestionReusePolicy.name + it + } .orElseGet { AdminInterviewSetting( settingKey = TECH_QUESTION_REUSE_POLICY_KEY, settingValue = request.techQuestionReusePolicy.name ) } - saved.settingValue = request.techQuestionReusePolicy.name val updated = adminInterviewSettingRepository.save(saved) return AdminInterviewSettingsResponse( techQuestionReusePolicy = updated.toTechQuestionReusePolicy(), @@ -55,7 +61,15 @@ class AdminInterviewSettingsService( private fun AdminInterviewSetting.toTechQuestionReusePolicy(): TechQuestionReusePolicy { return runCatching { TechQuestionReusePolicy.valueOf(settingValue) } - .getOrDefault(DEFAULT_TECH_QUESTION_REUSE_POLICY) + .getOrElse { ex -> + logger.warn( + "Unknown tech question reuse policy settingValue={}, fallback={}", + settingValue, + DEFAULT_TECH_QUESTION_REUSE_POLICY, + ex + ) + DEFAULT_TECH_QUESTION_REUSE_POLICY + } } private fun ensureAdmin(principal: AuthPrincipal) { diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt index e1950e3..872761d 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt @@ -574,9 +574,16 @@ class InterviewPracticeService( techQuestionReusePolicy: TechQuestionReusePolicy ): Boolean { if (request.setId != null) return true + if (!hasExplicitTechSelection(request)) return true return techQuestionReusePolicy == TechQuestionReusePolicy.REUSE_MATCHING } + private fun hasExplicitTechSelection(request: StartTechInterviewRequest): Boolean { + return request.categoryId != null || + !request.jobName.isNullOrBlank() || + !request.skillName.isNullOrBlank() + } + private fun generateCategoryQuestions( owner: com.cw.vlainter.domain.user.entity.User, request: StartTechInterviewRequest, diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt index 64c001b..0c71296 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt @@ -6,6 +6,7 @@ import com.cw.vlainter.domain.site.dto.ReorderPatchNotesRequest import com.cw.vlainter.domain.site.dto.UpdatePatchNoteRequest import com.cw.vlainter.domain.site.service.PatchNoteService import com.cw.vlainter.global.security.AuthPrincipal +import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping @@ -32,7 +33,7 @@ class AdminPatchNoteController( @PostMapping fun createPatchNote( @AuthenticationPrincipal principal: AuthPrincipal, - @RequestBody request: CreatePatchNoteRequest + @Valid @RequestBody request: CreatePatchNoteRequest ): ResponseEntity { return ResponseEntity.ok(patchNoteService.createPatchNote(principal, request)) } @@ -41,7 +42,7 @@ class AdminPatchNoteController( fun updatePatchNote( @AuthenticationPrincipal principal: AuthPrincipal, @PathVariable patchNoteId: Long, - @RequestBody request: UpdatePatchNoteRequest + @Valid @RequestBody request: UpdatePatchNoteRequest ): ResponseEntity { return ResponseEntity.ok(patchNoteService.updatePatchNote(principal, patchNoteId, request)) } @@ -49,7 +50,7 @@ class AdminPatchNoteController( @PatchMapping("/reorder") fun reorderPatchNotes( @AuthenticationPrincipal principal: AuthPrincipal, - @RequestBody request: ReorderPatchNotesRequest + @Valid @RequestBody request: ReorderPatchNotesRequest ): ResponseEntity> { return ResponseEntity.ok(patchNoteService.reorderPatchNotes(principal, request)) } diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt index cfbc826..dad3b5e 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt @@ -4,6 +4,7 @@ import com.cw.vlainter.domain.site.dto.AdminSiteSettingsResponse import com.cw.vlainter.domain.site.dto.UpdateAdminSiteSettingsRequest import com.cw.vlainter.domain.site.service.SiteSettingsService import com.cw.vlainter.global.security.AuthPrincipal +import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping @@ -27,7 +28,7 @@ class AdminSiteSettingsController( @PatchMapping fun updateAdminSettings( @AuthenticationPrincipal principal: AuthPrincipal, - @RequestBody request: UpdateAdminSiteSettingsRequest + @Valid @RequestBody request: UpdateAdminSiteSettingsRequest ): ResponseEntity { return ResponseEntity.ok(siteSettingsService.updateAdminSettings(principal, request)) } diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt index 3266dc3..da1824e 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt @@ -1,5 +1,8 @@ package com.cw.vlainter.domain.site.dto +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Size import java.time.OffsetDateTime data class PublicPatchNoteResponse( @@ -20,19 +23,28 @@ data class AdminPatchNoteResponse( ) data class CreatePatchNoteRequest( + @field:NotBlank(message = "패치노트 제목을 입력해 주세요.") + @field:Size(max = 160, message = "패치노트 제목은 160자 이하여야 합니다.") val title: String, + @field:NotBlank(message = "패치노트 본문을 입력해 주세요.") + @field:Size(max = 5000, message = "패치노트 본문은 5000자 이하여야 합니다.") val body: String, val sortOrder: Int? = null, val isPublished: Boolean = true ) data class UpdatePatchNoteRequest( + @field:NotBlank(message = "패치노트 제목을 입력해 주세요.") + @field:Size(max = 160, message = "패치노트 제목은 160자 이하여야 합니다.") val title: String, + @field:NotBlank(message = "패치노트 본문을 입력해 주세요.") + @field:Size(max = 5000, message = "패치노트 본문은 5000자 이하여야 합니다.") val body: String, val sortOrder: Int? = null, val isPublished: Boolean = true ) data class ReorderPatchNotesRequest( + @field:NotEmpty(message = "정렬할 패치노트가 없습니다.") val patchNoteIds: List ) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt index fffa629..dab92d4 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt @@ -1,5 +1,6 @@ package com.cw.vlainter.domain.site.dto +import jakarta.validation.constraints.NotBlank import java.time.OffsetDateTime data class PublicSiteSettingsResponse( @@ -12,5 +13,6 @@ data class AdminSiteSettingsResponse( ) data class UpdateAdminSiteSettingsRequest( + @field:NotBlank(message = "버전 라벨을 입력해 주세요.") val landingVersionLabel: String ) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt index b0d06cb..e82b299 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt @@ -69,10 +69,13 @@ class PatchNoteService( @Transactional fun reorderPatchNotes(principal: AuthPrincipal, request: ReorderPatchNotesRequest): List { ensureAdmin(principal) - val requestedIds = request.patchNoteIds.distinct() + val requestedIds = request.patchNoteIds if (requestedIds.isEmpty()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "정렬할 패치노트가 없습니다.") } + if (requestedIds.size != requestedIds.toSet().size) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "중복된 패치노트 ID가 있습니다.") + } val allPatchNotes = patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc() val allIds = allPatchNotes.map { it.id } if (!allIds.containsAll(requestedIds)) { diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt index bce3bb9..8c0b10e 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt @@ -43,13 +43,16 @@ class SiteSettingsService( throw ResponseStatusException(HttpStatus.BAD_REQUEST, "버전 라벨을 입력해 주세요.") } val saved = adminInterviewSettingRepository.findById(LANDING_VERSION_LABEL_KEY) + .map { + it.settingValue = normalizedLabel + it + } .orElseGet { AdminInterviewSetting( settingKey = LANDING_VERSION_LABEL_KEY, settingValue = normalizedLabel ) } - saved.settingValue = normalizedLabel val updated = adminInterviewSettingRepository.save(saved) return AdminSiteSettingsResponse( landingVersionLabel = updated.settingValue, @@ -71,6 +74,6 @@ class SiteSettingsService( companion object { const val LANDING_VERSION_LABEL_KEY = "landing_version_label" - const val DEFAULT_LANDING_VERSION_LABEL = "v0.4" + const val DEFAULT_LANDING_VERSION_LABEL = "v0.5" } } diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt index 7524c00..eff2eda 100644 --- a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt @@ -145,13 +145,7 @@ class InterviewPracticeServiceTests { request.difficulty, request.sourceTag ) - then(interviewAiOrchestrator).should(never()).generateTechQuestions( - "백엔드개발자", - "Spring", - QuestionDifficulty.MEDIUM, - 5, - InterviewLanguage.KO - ) + then(interviewAiOrchestrator).shouldHaveNoInteractions() } @Test @@ -292,6 +286,73 @@ class InterviewPracticeServiceTests { ) } + @Test + fun `무조건 생성 정책이어도 기술 선택값이 없으면 기존 후보를 재사용한다`() { + val user = createUser() + val category = createCategory() + val question = createQuestion(id = 303L, category = category, text = "기존 질문을 그대로 재사용하는 흐름을 검증합니다.") + val request = StartTechInterviewRequest( + categoryId = null, + jobName = null, + skillName = null, + difficulty = QuestionDifficulty.MEDIUM, + questionCount = 1 + ) + + given(userRepository.findById(1L)).willReturn(Optional.of(user)) + given(adminInterviewSettingsService.getTechQuestionReusePolicy()).willReturn(TechQuestionReusePolicy.ALWAYS_GENERATE) + given( + questionRepository.findCandidatesForUser( + user.id, + com.cw.vlainter.domain.interview.entity.QuestionSetStatus.ACTIVE, + QuestionSetVisibility.GLOBAL, + request.difficulty, + request.sourceTag + ) + ).willReturn(listOf(question)) + given(questionRepository.findByIdAndDeletedAtIsNull(question.id)).willReturn(question) + given(interviewSessionRepository.save(any(InterviewSession::class.java))).willAnswer { invocation -> + val source = invocation.getArgument(0) + InterviewSession( + id = 503L, + user = source.user, + mode = source.mode, + status = source.status, + questionSet = source.questionSet, + revealPolicy = source.revealPolicy, + configJson = source.configJson + ) + } + given(interviewTurnRepository.save(any(InterviewTurn::class.java))).willAnswer { invocation -> + val source = invocation.getArgument(0) + InterviewTurn( + id = 903L, + session = source.session, + turnNo = source.turnNo, + sourceTag = source.sourceTag, + question = source.question, + documentQuestion = source.documentQuestion, + questionTextSnapshot = source.questionTextSnapshot, + categorySnapshot = source.categorySnapshot, + jobSnapshot = source.jobSnapshot, + skillSnapshot = source.skillSnapshot, + category = source.category, + difficulty = source.difficulty, + tagsJson = source.tagsJson, + ragContextJson = source.ragContextJson + ) + } + given(questionSetItemRepository.existsInAiGeneratedSetByQuestionId(question.id)).willReturn(false) + + val result = service().startTechInterview( + principal = AuthPrincipal(1L, "user@vlainter.com", "S", UserRole.USER), + request = request + ) + + assertThat(result.currentQuestion.questionId).isEqualTo(question.id) + then(interviewAiOrchestrator).shouldHaveNoInteractions() + } + private fun service() = InterviewPracticeService( interviewAiOrchestrator = interviewAiOrchestrator, aiRoutingContextHolder = aiRoutingContextHolder, diff --git a/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt index d95e6f1..537b695 100644 --- a/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt +++ b/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt @@ -117,6 +117,19 @@ class PatchNoteServiceTests { then(patchNoteRepository).should().delete(existing) } + @Test + fun `패치노트 reorder 요청에 중복 ID가 있으면 거부한다`() { + val exception = assertThrows(ResponseStatusException::class.java) { + service().reorderPatchNotes( + adminPrincipal(), + com.cw.vlainter.domain.site.dto.ReorderPatchNotesRequest(listOf(1L, 1L, 2L)) + ) + } + + assertThat(exception.statusCode.value()).isEqualTo(400) + assertThat(exception.reason).isEqualTo("중복된 패치노트 ID가 있습니다.") + } + private fun service(): PatchNoteService = PatchNoteService(patchNoteRepository) @Suppress("UNCHECKED_CAST") From dc1e01855c719de71afd5203dfa306ae76b5133a Mon Sep 17 00:00:00 2001 From: rktclgh Date: Sat, 14 Mar 2026 11:49:54 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt | 2 +- .../kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt | 2 ++ .../com/cw/vlainter/domain/site/service/PatchNoteService.kt | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt index da1824e..cec5771 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt @@ -41,7 +41,7 @@ data class UpdatePatchNoteRequest( @field:Size(max = 5000, message = "패치노트 본문은 5000자 이하여야 합니다.") val body: String, val sortOrder: Int? = null, - val isPublished: Boolean = true + val isPublished: Boolean? = null ) data class ReorderPatchNotesRequest( diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt index dab92d4..7afd43b 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt @@ -1,6 +1,7 @@ package com.cw.vlainter.domain.site.dto import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size import java.time.OffsetDateTime data class PublicSiteSettingsResponse( @@ -14,5 +15,6 @@ data class AdminSiteSettingsResponse( data class UpdateAdminSiteSettingsRequest( @field:NotBlank(message = "버전 라벨을 입력해 주세요.") + @field:Size(max = 100, message = "버전 라벨은 100자 이하여야 합니다.") val landingVersionLabel: String ) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt index e82b299..24f2471 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt @@ -62,7 +62,7 @@ class PatchNoteService( throw ResponseStatusException(HttpStatus.BAD_REQUEST, "패치노트 본문을 입력해 주세요.") } request.sortOrder?.let { patchNote.sortOrder = it } - patchNote.isPublished = request.isPublished + request.isPublished?.let { patchNote.isPublished = it } return patchNoteRepository.save(patchNote).toAdminResponse() } From 9964ff1ad801c6c57b0375c1395439ac717c586d Mon Sep 17 00:00:00 2001 From: rktclgh Date: Sat, 14 Mar 2026 12:00:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vlainter/domain/site/repository/PatchNoteRepository.kt | 1 + .../cw/vlainter/domain/site/service/PatchNoteService.kt | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt b/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt index 171c99d..e305aed 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt @@ -6,4 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface PatchNoteRepository : JpaRepository { fun findAllByIsPublishedTrueOrderBySortOrderAscCreatedAtDesc(): List fun findAllByOrderBySortOrderAscCreatedAtDesc(): List + fun findFirstByOrderBySortOrderAscCreatedAtDesc(): PatchNote? } diff --git a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt index 24f2471..c1ed6ff 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt @@ -34,7 +34,7 @@ class PatchNoteService( @Transactional fun createPatchNote(principal: AuthPrincipal, request: CreatePatchNoteRequest): AdminPatchNoteResponse { ensureAdmin(principal) - val nextSortOrder = request.sortOrder ?: ((patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc().firstOrNull()?.sortOrder ?: 0) - 10) + val nextSortOrder = request.sortOrder ?: ((patchNoteRepository.findFirstByOrderBySortOrderAscCreatedAtDesc()?.sortOrder ?: 0) - 10) val saved = patchNoteRepository.save( PatchNote( title = request.title.trim().ifBlank { @@ -70,10 +70,11 @@ class PatchNoteService( fun reorderPatchNotes(principal: AuthPrincipal, request: ReorderPatchNotesRequest): List { ensureAdmin(principal) val requestedIds = request.patchNoteIds + val requestedIdSet = requestedIds.toSet() if (requestedIds.isEmpty()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "정렬할 패치노트가 없습니다.") } - if (requestedIds.size != requestedIds.toSet().size) { + if (requestedIds.size != requestedIdSet.size) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "중복된 패치노트 ID가 있습니다.") } val allPatchNotes = patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc() @@ -81,7 +82,7 @@ class PatchNoteService( if (!allIds.containsAll(requestedIds)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "일부 패치노트를 찾을 수 없습니다.") } - val remainingIds = allIds.filterNot { requestedIds.contains(it) } + val remainingIds = allIds.filterNot { requestedIdSet.contains(it) } val orderedIds = requestedIds + remainingIds val patchNoteById = allPatchNotes.associateBy { it.id } orderedIds.forEachIndexed { index, patchNoteId ->