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..5cb1a4e --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/AdminInterviewSettingsService.kt @@ -0,0 +1,85 @@ +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.slf4j.LoggerFactory +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 +) { + private val logger = LoggerFactory.getLogger(javaClass) + + @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) + .map { + it.settingValue = request.techQuestionReusePolicy.name + it + } + .orElseGet { + AdminInterviewSetting( + settingKey = TECH_QUESTION_REUSE_POLICY_KEY, + 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) } + .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) { + 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..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 @@ -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,21 @@ class InterviewPracticeService( } } + private fun shouldReuseMatchingQuestions( + request: StartTechInterviewRequest, + 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 new file mode 100644 index 0000000..0c71296 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminPatchNoteController.kt @@ -0,0 +1,66 @@ +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 +import jakarta.validation.Valid +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, + @Valid @RequestBody request: CreatePatchNoteRequest + ): ResponseEntity { + return ResponseEntity.ok(patchNoteService.createPatchNote(principal, request)) + } + + @PatchMapping("/{patchNoteId}") + fun updatePatchNote( + @AuthenticationPrincipal principal: AuthPrincipal, + @PathVariable patchNoteId: Long, + @Valid @RequestBody request: UpdatePatchNoteRequest + ): ResponseEntity { + return ResponseEntity.ok(patchNoteService.updatePatchNote(principal, patchNoteId, request)) + } + + @PatchMapping("/reorder") + fun reorderPatchNotes( + @AuthenticationPrincipal principal: AuthPrincipal, + @Valid @RequestBody request: ReorderPatchNotesRequest + ): ResponseEntity> { + return ResponseEntity.ok(patchNoteService.reorderPatchNotes(principal, 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/AdminSiteSettingsController.kt b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt new file mode 100644 index 0000000..dad3b5e --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/controller/AdminSiteSettingsController.kt @@ -0,0 +1,35 @@ +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 jakarta.validation.Valid +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, + @Valid @RequestBody request: UpdateAdminSiteSettingsRequest + ): ResponseEntity { + return ResponseEntity.ok(siteSettingsService.updateAdminSettings(principal, request)) + } +} 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/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 new file mode 100644 index 0000000..cec5771 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/PatchNoteDtos.kt @@ -0,0 +1,50 @@ +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( + 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( + @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? = null +) + +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 new file mode 100644 index 0000000..7afd43b --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/dto/SiteSettingsDtos.kt @@ -0,0 +1,20 @@ +package com.cw.vlainter.domain.site.dto + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import java.time.OffsetDateTime + +data class PublicSiteSettingsResponse( + val landingVersionLabel: String +) + +data class AdminSiteSettingsResponse( + val landingVersionLabel: String, + val updatedAt: OffsetDateTime? +) + +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/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..e305aed --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/repository/PatchNoteRepository.kt @@ -0,0 +1,10 @@ +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 + 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 new file mode 100644 index 0000000..c1ed6ff --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/PatchNoteService.kt @@ -0,0 +1,126 @@ +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 +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 nextSortOrder = request.sortOrder ?: ((patchNoteRepository.findFirstByOrderBySortOrderAscCreatedAtDesc()?.sortOrder ?: 0) - 10) + 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 = nextSortOrder, + 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, "패치노트 본문을 입력해 주세요.") + } + request.sortOrder?.let { patchNote.sortOrder = it } + request.isPublished?.let { patchNote.isPublished = it } + return patchNoteRepository.save(patchNote).toAdminResponse() + } + + @Transactional + 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 != requestedIdSet.size) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "중복된 패치노트 ID가 있습니다.") + } + val allPatchNotes = patchNoteRepository.findAllByOrderBySortOrderAscCreatedAtDesc() + val allIds = allPatchNotes.map { it.id } + if (!allIds.containsAll(requestedIds)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "일부 패치노트를 찾을 수 없습니다.") + } + val remainingIds = allIds.filterNot { requestedIdSet.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) + 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/domain/site/service/SiteSettingsService.kt b/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt new file mode 100644 index 0000000..8c0b10e --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/site/service/SiteSettingsService.kt @@ -0,0 +1,79 @@ +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) + .map { + it.settingValue = normalizedLabel + it + } + .orElseGet { + AdminInterviewSetting( + settingKey = LANDING_VERSION_LABEL_KEY, + 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.5" + } +} 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..b1e7f9c 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,8 @@ 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() + registry = registry.requestMatchers(HttpMethod.GET, "/api/site/settings").permitAll() if (docsEnabled) { registry = registry.requestMatchers(*PUBLIC_DOCS_PATHS).permitAll() } 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..eff2eda --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeServiceTests.kt @@ -0,0 +1,426 @@ +@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).shouldHaveNoInteractions() + } + + @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 + ) + } + + @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, + 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 = "[]" + ) +} 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..537b695 --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/site/service/PatchNoteServiceTests.kt @@ -0,0 +1,144 @@ +@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) + } + + @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") + 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) +}