Skip to content
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<AdminInterviewSettingsResponse> {
return ResponseEntity.ok(adminInterviewSettingsService.getSettings(principal))
}

@PatchMapping("/settings")
fun updateSettings(
@AuthenticationPrincipal principal: AuthPrincipal,
@Valid @RequestBody request: UpdateAdminInterviewSettingsRequest
): ResponseEntity<AdminInterviewSettingsResponse> {
return ResponseEntity.ok(adminInterviewSettingsService.updateSettings(principal, request))
}

@GetMapping("/sets")
fun getAllSets(
@AuthenticationPrincipal principal: AuthPrincipal,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add DB migration for new settings and patch note tables

This commit adds entities for admin_interview_settings and patch_notes (AdminInterviewSetting and PatchNote) but does not include any schema migration/DDL in the repository, so deployments that use the default spring.jpa.hibernate.ddl-auto=${JPA_DDL_AUTO:validate} will fail at startup when those tables are not pre-created. Please add the corresponding migration in the same change set to avoid runtime schema-validation failures.

Useful? React with ๐Ÿ‘ย / ๐Ÿ‘Ž.

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()
}
}
Comment on lines +15 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

updatedAt ํ•„๋“œ๊ฐ€ ์ƒ์„ฑ์ž์—์„œ OffsetDateTime.now()๋กœ ์ดˆ๊ธฐํ™”๋˜๊ณ , @PrePersist ๋ผ์ดํ”„์‚ฌ์ดํด ์ฝœ๋ฐฑ์—์„œ๋„ ๋‹ค์‹œ OffsetDateTime.now()๋กœ ์„ค์ •๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ์ค‘๋ณต๋œ ๋กœ์ง์ž…๋‹ˆ๋‹ค. ํ•„๋“œ๋ฅผ ์ฃผ ์ƒ์„ฑ์ž ๋ฐ–์œผ๋กœ ์ด๋™์‹œํ‚ค๊ณ  lateinit์œผ๋กœ ์„ ์–ธํ•˜์—ฌ ์ดˆ๊ธฐํ™” ๋กœ์ง์„ @PrePersist์—์„œ๋งŒ ๊ด€๋ฆฌํ•˜๋„๋ก ํ•˜๋ฉด ์ฝ”๋“œ๊ฐ€ ๋” ๋ช…ํ™•ํ•ด์ง‘๋‹ˆ๋‹ค.

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)
    lateinit var updatedAt: OffsetDateTime

    @PrePersist
    fun prePersist() {
        updatedAt = OffsetDateTime.now()
    }

    @PreUpdate
    fun preUpdate() {
        updatedAt = OffsetDateTime.now()
    }
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.cw.vlainter.domain.interview.entity

enum class TechQuestionReusePolicy {
REUSE_MATCHING,
ALWAYS_GENERATE
}
Original file line number Diff line number Diff line change
@@ -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<AdminInterviewSetting, String>
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -169,6 +180,7 @@ class InterviewPracticeService(
"categoryName" to resolvedSkillName,
"jobName" to resolvedJobName,
"practiceMode" to practiceMode.name,
"techQuestionReusePolicy" to techQuestionReusePolicy.name,
"selectedDocuments" to emptyList<Map<String, Any?>>(),
"localizedQueue" to localizedQueue,
"providerUsed" to aiRoutingContextHolder.snapshot().providerUsed?.name,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<AdminPatchNoteResponse>> {
return ResponseEntity.ok(patchNoteService.getAdminPatchNotes(principal))
}

@PostMapping
fun createPatchNote(
@AuthenticationPrincipal principal: AuthPrincipal,
@Valid @RequestBody request: CreatePatchNoteRequest
): ResponseEntity<AdminPatchNoteResponse> {
return ResponseEntity.ok(patchNoteService.createPatchNote(principal, request))
}

@PatchMapping("/{patchNoteId}")
fun updatePatchNote(
@AuthenticationPrincipal principal: AuthPrincipal,
@PathVariable patchNoteId: Long,
@Valid @RequestBody request: UpdatePatchNoteRequest
): ResponseEntity<AdminPatchNoteResponse> {
return ResponseEntity.ok(patchNoteService.updatePatchNote(principal, patchNoteId, request))
}

@PatchMapping("/reorder")
fun reorderPatchNotes(
@AuthenticationPrincipal principal: AuthPrincipal,
@Valid @RequestBody request: ReorderPatchNotesRequest
): ResponseEntity<List<AdminPatchNoteResponse>> {
return ResponseEntity.ok(patchNoteService.reorderPatchNotes(principal, request))
}

@DeleteMapping("/{patchNoteId}")
fun deletePatchNote(
@AuthenticationPrincipal principal: AuthPrincipal,
@PathVariable patchNoteId: Long
): ResponseEntity<Map<String, String>> {
patchNoteService.deletePatchNote(principal, patchNoteId)
return ResponseEntity.ok(mapOf("message" to "ํŒจ์น˜๋…ธํŠธ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."))
}
}
Original file line number Diff line number Diff line change
@@ -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<AdminSiteSettingsResponse> {
return ResponseEntity.ok(siteSettingsService.getAdminSettings(principal))
}

@PatchMapping
fun updateAdminSettings(
@AuthenticationPrincipal principal: AuthPrincipal,
@Valid @RequestBody request: UpdateAdminSiteSettingsRequest
): ResponseEntity<AdminSiteSettingsResponse> {
return ResponseEntity.ok(siteSettingsService.updateAdminSettings(principal, request))
}
}
Original file line number Diff line number Diff line change
@@ -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<List<PublicPatchNoteResponse>> {
return ResponseEntity.ok(patchNoteService.getPublishedPatchNotes())
}
}
Loading
Loading