diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e69de29b..3407af23 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } +} diff --git a/src/main/kotlin/codel/admin/business/AdminService.kt b/src/main/kotlin/codel/admin/business/AdminService.kt index 395bf7c1..b4e9a072 100644 --- a/src/main/kotlin/codel/admin/business/AdminService.kt +++ b/src/main/kotlin/codel/admin/business/AdminService.kt @@ -14,6 +14,7 @@ import codel.notification.domain.NotificationType import codel.question.business.QuestionService import codel.question.domain.Question import codel.question.domain.QuestionCategory +import codel.question.domain.QuestionGroup import codel.verification.domain.StandardVerificationImage import codel.verification.domain.VerificationImage import codel.verification.infrastructure.StandardVerificationImageJpaRepository @@ -290,6 +291,14 @@ class AdminService( isActive: Boolean?, pageable: Pageable ): Page = questionService.findQuestionsWithFilter(keyword, category, isActive, pageable) + + fun findQuestionsWithFilterV2( + keyword: String?, + category: String?, + questionGroup: String?, + isActive: Boolean?, + pageable: Pageable + ): Page = questionService.findQuestionsWithFilterV2(keyword, category, questionGroup, isActive, pageable) fun findQuestionById(questionId: Long): Question = questionService.findQuestionById(questionId) @@ -300,6 +309,15 @@ class AdminService( description: String?, isActive: Boolean ): Question = questionService.createQuestion(content, category, description, isActive) + + @Transactional + fun createQuestionV2( + content: String, + category: QuestionCategory, + questionGroup: QuestionGroup, + description: String?, + isActive: Boolean + ): Question = questionService.createQuestionV2(content, category, questionGroup, description, isActive) @Transactional fun updateQuestion( @@ -309,6 +327,16 @@ class AdminService( description: String?, isActive: Boolean ): Question = questionService.updateQuestion(questionId, content, category, description, isActive) + + @Transactional + fun updateQuestionV2( + questionId: Long, + content: String, + category: QuestionCategory, + questionGroup: QuestionGroup, + description: String?, + isActive: Boolean + ): Question = questionService.updateQuestionV2(questionId, content, category, questionGroup, description, isActive) @Transactional fun deleteQuestion(questionId: Long) = questionService.deleteQuestion(questionId) diff --git a/src/main/kotlin/codel/admin/presentation/AdminController.kt b/src/main/kotlin/codel/admin/presentation/AdminController.kt index f7ae5fa9..4e4a0eb2 100644 --- a/src/main/kotlin/codel/admin/presentation/AdminController.kt +++ b/src/main/kotlin/codel/admin/presentation/AdminController.kt @@ -9,6 +9,7 @@ import codel.admin.presentation.request.AdminLoginRequest import codel.admin.presentation.request.RejectProfileRequest import codel.member.domain.Member import codel.question.domain.QuestionCategory +import codel.question.domain.QuestionGroup import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletResponse import org.springframework.data.domain.Page @@ -321,8 +322,8 @@ class AdminController( "DONE" to adminService.countMembersByStatus("DONE"), "REJECT" to adminService.countMembersByStatus("REJECT"), "PHONE_VERIFIED" to adminService.countMembersByStatus("PHONE_VERIFIED"), - "WITHDRAWN" to adminService.countMembersByStatus("WITHDRAWN"), - "PERSONALITY_COMPLETED" to adminService.countMembersByStatus("PERSONALITY_COMPLETED") + "PERSONALITY_COMPLETED" to adminService.countMembersByStatus("PERSONALITY_COMPLETED"), + "WITHDRAWN" to adminService.countMembersByStatus("WITHDRAWN") ) model.addAttribute("members", members) @@ -360,22 +361,30 @@ class AdminController( fun questionList( model: Model, @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) purpose: String?, @RequestParam(required = false) category: String?, + @RequestParam(required = false) questionGroup: String?, @RequestParam(required = false) isActive: Boolean?, @PageableDefault(size = 20) pageable: Pageable ): String { - val questions = adminService.findQuestionsWithFilter(keyword, category, isActive, pageable) + val questions = adminService.findQuestionsWithFilterV2(keyword, category, questionGroup, isActive, pageable) model.addAttribute("questions", questions) model.addAttribute("categories", QuestionCategory.values()) - model.addAttribute("selectedKeyword", keyword ?: "") - model.addAttribute("selectedCategory", category ?: "") - model.addAttribute("selectedIsActive", isActive?.toString() ?: "") + model.addAttribute("questionGroups", QuestionGroup.values()) + model.addAttribute("searchParams", mapOf( + "keyword" to (keyword ?: ""), + "purpose" to (purpose ?: ""), + "category" to (category ?: ""), + "questionGroup" to (questionGroup ?: ""), + "isActive" to (isActive?.toString() ?: "") + )) return "questionList" } @GetMapping("/v1/admin/questions/new") fun questionForm(model: Model): String { model.addAttribute("categories", QuestionCategory.values()) + model.addAttribute("questionGroups", QuestionGroup.values()) return "questionForm" } @@ -383,13 +392,20 @@ class AdminController( fun createQuestion( @RequestParam content: String, @RequestParam category: String, + @RequestParam(required = false) questionGroup: String?, @RequestParam(required = false) description: String?, @RequestParam(defaultValue = "true") isActive: Boolean, redirectAttributes: RedirectAttributes ): String { try { val questionCategory = QuestionCategory.valueOf(category) - adminService.createQuestion(content, questionCategory, description, isActive) + // 회원가입 전용 카테고리(채팅 미사용)는 자동으로 RANDOM 그룹 지정 + val group = if (questionCategory.usedInSignup && !questionCategory.usedInChat) { + QuestionGroup.RANDOM + } else { + QuestionGroup.valueOf(questionGroup ?: "RANDOM") + } + adminService.createQuestionV2(content, questionCategory, group, description, isActive) redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 등록되었습니다.") } catch (e: Exception) { redirectAttributes.addFlashAttribute("error", "질문 등록에 실패했습니다: ${e.message}") @@ -400,21 +416,12 @@ class AdminController( @GetMapping("/v1/admin/questions/{questionId}/edit") fun editQuestionForm( @PathVariable questionId: Long, - @RequestParam(required = false) keyword: String?, - @RequestParam(required = false) category: String?, - @RequestParam(required = false) isActive: String?, - @RequestParam(required = false, defaultValue = "0") page: Int, - @RequestParam(required = false, defaultValue = "20") size: Int, model: Model ): String { val question = adminService.findQuestionById(questionId) model.addAttribute("question", question) model.addAttribute("categories", QuestionCategory.values()) - model.addAttribute("filterKeyword", keyword) - model.addAttribute("filterCategory", category) - model.addAttribute("filterIsActive", isActive) - model.addAttribute("filterPage", page) - model.addAttribute("filterSize", size) + model.addAttribute("questionGroups", QuestionGroup.values()) return "questionEditForm" } @@ -423,43 +430,30 @@ class AdminController( @PathVariable questionId: Long, @RequestParam content: String, @RequestParam category: String, + @RequestParam(required = false) questionGroup: String?, @RequestParam(required = false) description: String?, @RequestParam(defaultValue = "false") isActive: Boolean, - @RequestParam(required = false) keyword: String?, - @RequestParam(required = false) filterCategory: String?, - @RequestParam(required = false) filterIsActive: String?, - @RequestParam(required = false, defaultValue = "0") page: Int, - @RequestParam(required = false, defaultValue = "20") size: Int, redirectAttributes: RedirectAttributes ): String { try { val questionCategory = QuestionCategory.valueOf(category) - adminService.updateQuestion(questionId, content, questionCategory, description, isActive) + // 회원가입 전용 카테고리(채팅 미사용)는 자동으로 RANDOM 그룹 지정 + val group = if (questionCategory.usedInSignup && !questionCategory.usedInChat) { + QuestionGroup.RANDOM + } else { + QuestionGroup.valueOf(questionGroup ?: "RANDOM") + } + adminService.updateQuestionV2(questionId, content, questionCategory, group, description, isActive) redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 수정되었습니다.") } catch (e: Exception) { redirectAttributes.addFlashAttribute("error", "질문 수정에 실패했습니다: ${e.message}") } - - // 필터 조건 유지하여 리다이렉트 - val params = mutableListOf() - keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") } - filterCategory?.let { if (it.isNotBlank()) params.add("category=$it") } - filterIsActive?.let { if (it.isNotBlank()) params.add("isActive=$it") } - params.add("page=$page") - params.add("size=$size") - - val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else "" - return "redirect:/v1/admin/questions$queryString" + return "redirect:/v1/admin/questions" } @PostMapping("/v1/admin/questions/{questionId}/delete") fun deleteQuestion( @PathVariable questionId: Long, - @RequestParam(required = false) keyword: String?, - @RequestParam(required = false) category: String?, - @RequestParam(required = false) isActive: String?, - @RequestParam(required = false, defaultValue = "0") page: Int, - @RequestParam(required = false, defaultValue = "20") size: Int, redirectAttributes: RedirectAttributes ): String { try { @@ -468,27 +462,12 @@ class AdminController( } catch (e: Exception) { redirectAttributes.addFlashAttribute("error", "질문 삭제에 실패했습니다: ${e.message}") } - - // 필터 조건 유지하여 리다이렉트 - val params = mutableListOf() - keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") } - category?.let { if (it.isNotBlank()) params.add("category=$it") } - isActive?.let { if (it.isNotBlank()) params.add("isActive=$it") } - params.add("page=$page") - params.add("size=$size") - - val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else "" - return "redirect:/v1/admin/questions$queryString" + return "redirect:/v1/admin/questions" } @PostMapping("/v1/admin/questions/{questionId}/toggle") fun toggleQuestionStatus( @PathVariable questionId: Long, - @RequestParam(required = false) keyword: String?, - @RequestParam(required = false) category: String?, - @RequestParam(required = false) isActive: String?, - @RequestParam(required = false, defaultValue = "0") page: Int, - @RequestParam(required = false, defaultValue = "20") size: Int, redirectAttributes: RedirectAttributes ): String { try { @@ -498,17 +477,7 @@ class AdminController( } catch (e: Exception) { redirectAttributes.addFlashAttribute("error", "질문 상태 변경에 실패했습니다: ${e.message}") } - - // 필터 조건 유지하여 리다이렉트 - val params = mutableListOf() - keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") } - category?.let { if (it.isNotBlank()) params.add("category=$it") } - isActive?.let { if (it.isNotBlank()) params.add("isActive=$it") } - params.add("page=$page") - params.add("size=$size") - - val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else "" - return "redirect:/v1/admin/questions$queryString" + return "redirect:/v1/admin/questions" } // ========== 신고 관리 ========== diff --git a/src/main/kotlin/codel/chat/business/ChatService.kt b/src/main/kotlin/codel/chat/business/ChatService.kt index dee9db1a..0c0a5785 100644 --- a/src/main/kotlin/codel/chat/business/ChatService.kt +++ b/src/main/kotlin/codel/chat/business/ChatService.kt @@ -495,6 +495,39 @@ class ChatService( return buildQuestionSendResult(requester, partner, savedChat) } + /** + * 특정 질문을 채팅방에 전송 (Strategy 패턴용) + * + * @param chatRoomId 채팅방 ID + * @param requester 요청 회원 + * @param question 전송할 질문 + * @return 저장된 채팅 정보 + */ + fun sendQuestionMessage(chatRoomId: Long, requester: Member, question: Question): SavedChatDto { + // 1. 채팅방 검증 + val chatRoom = chatRoomJpaRepository.findById(chatRoomId) + .orElseThrow { ChatException(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다.") } + + validateChatRoomMember(chatRoomId, requester) + val partner = findPartner(chatRoomId, requester) + + // 2. 질문 사용 표시 + questionService.markQuestionAsUsed(chatRoomId, question, requester) + + // 3. 채팅 메시지 생성 + val savedChat = createQuestionSystemMessage(chatRoom, question, requester) + chatRoom.updateRecentChat(savedChat) + + // 4. 결과 반환 + val result = buildQuestionSendResult(requester, partner, savedChat) + return SavedChatDto( + partner = result.partner, + requesterChatRoomResponse = result.requesterChatRoomResponse, + partnerChatRoomResponse = result.partnerChatRoomResponse, + chatResponse = result.chatResponse + ) + } + /** * 채팅방 멤버 권한 검증 */ diff --git a/src/main/kotlin/codel/chat/business/strategy/CategoryBasedQuestionStrategy.kt b/src/main/kotlin/codel/chat/business/strategy/CategoryBasedQuestionStrategy.kt new file mode 100644 index 00000000..cc5a38e3 --- /dev/null +++ b/src/main/kotlin/codel/chat/business/strategy/CategoryBasedQuestionStrategy.kt @@ -0,0 +1,61 @@ +package codel.chat.business.strategy + +import codel.chat.business.ChatService +import codel.chat.presentation.request.QuestionRecommendRequest +import codel.chat.presentation.response.QuestionRecommendResponseV2 +import codel.member.domain.Member +import codel.question.business.QuestionRecommendationResult +import codel.question.business.QuestionService +import codel.question.presentation.response.QuestionResponse +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +/** + * 카테고리 기반 질문 추천 전략 (1.3.0 이상) + * + * - 채팅방용 카테고리: 가치관, 텐션업 코드, 만약에 코드, 비밀 코드(19+) + * - A/B 그룹 정책 적용 (텐션업 제외) + */ +@Component +@Transactional +class CategoryBasedQuestionStrategy( + private val questionService: QuestionService, + private val chatService: ChatService +) : QuestionRecommendStrategy { + + override fun recommendQuestion( + chatRoomId: Long, + member: Member, + request: QuestionRecommendRequest + ): ResponseEntity { + // 카테고리 필수 검증 + val category = request.category + ?: return ResponseEntity.badRequest() + .body(mapOf("message" to "카테고리를 선택해주세요.")) + + // 채팅방용 카테고리 검증 + if (!category.isChatCategory()) { + return ResponseEntity.badRequest() + .body(mapOf("message" to "채팅방에서 사용할 수 없는 카테고리입니다.")) + } + + // 카테고리별 정책에 따른 질문 추천 + val result = questionService.recommendQuestionForChat(chatRoomId, category) + + return when (result) { + is QuestionRecommendationResult.Success -> { + val savedChat = chatService.sendQuestionMessage(chatRoomId, member, result.question) + ResponseEntity.ok( + QuestionRecommendResponseV2.success( + question = QuestionResponse.from(result.question), + chat = savedChat + ) + ) + } + is QuestionRecommendationResult.Exhausted -> { + ResponseEntity.ok(QuestionRecommendResponseV2.exhausted()) + } + } + } +} diff --git a/src/main/kotlin/codel/chat/business/strategy/LegacyRandomQuestionStrategy.kt b/src/main/kotlin/codel/chat/business/strategy/LegacyRandomQuestionStrategy.kt new file mode 100644 index 00000000..883a3d40 --- /dev/null +++ b/src/main/kotlin/codel/chat/business/strategy/LegacyRandomQuestionStrategy.kt @@ -0,0 +1,31 @@ +package codel.chat.business.strategy + +import codel.chat.business.ChatService +import codel.chat.presentation.request.QuestionRecommendRequest +import codel.chat.presentation.response.QuestionSendResult +import codel.member.domain.Member +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +/** + * 기존 랜덤 질문 추천 전략 (1.3.0 미만) + * + * - 카테고리 구분 없이 미사용 질문에서 랜덤 추천 + * - 기존 API 응답 형식 유지 (ChatResponse) + */ +@Component +@Transactional +class LegacyRandomQuestionStrategy( + private val chatService: ChatService +) : QuestionRecommendStrategy { + + override fun recommendQuestion( + chatRoomId: Long, + member: Member, + request: QuestionRecommendRequest + ): ResponseEntity { + val result = chatService.sendRandomQuestion(chatRoomId, member) + return ResponseEntity.ok(result) + } +} diff --git a/src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategy.kt b/src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategy.kt new file mode 100644 index 00000000..9b6cc18b --- /dev/null +++ b/src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategy.kt @@ -0,0 +1,27 @@ +package codel.chat.business.strategy + +import codel.chat.presentation.request.QuestionRecommendRequest +import codel.member.domain.Member +import org.springframework.http.ResponseEntity + +/** + * 채팅방 질문 추천 전략 인터페이스 + * + * 앱 버전에 따라 다른 추천 로직을 적용하기 위한 Strategy 패턴 + */ +interface QuestionRecommendStrategy { + + /** + * 질문 추천 처리 + * + * @param chatRoomId 채팅방 ID + * @param member 요청한 회원 + * @param request 추천 요청 (카테고리 등) + * @return 추천 결과 응답 + */ + fun recommendQuestion( + chatRoomId: Long, + member: Member, + request: QuestionRecommendRequest + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategyResolver.kt b/src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategyResolver.kt new file mode 100644 index 00000000..de0f53ff --- /dev/null +++ b/src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategyResolver.kt @@ -0,0 +1,69 @@ +package codel.chat.business.strategy + +import codel.config.Loggable +import org.springframework.stereotype.Component + +/** + * 질문 추천 전략 선택 Resolver + * + * 앱 버전을 기반으로 적절한 QuestionRecommendStrategy를 선택합니다. + * - 1.3.0 미만: 기존 랜덤 질문 추천 (LegacyRandomQuestionStrategy) + * - 1.3.0 이상: 카테고리 기반 질문 추천 (CategoryBasedQuestionStrategy) + */ +@Component +class QuestionRecommendStrategyResolver( + private val categoryBasedStrategy: CategoryBasedQuestionStrategy, + private val legacyRandomStrategy: LegacyRandomQuestionStrategy +) : Loggable { + + companion object { + private const val CATEGORY_FEATURE_VERSION_MAJOR = 1 + private const val CATEGORY_FEATURE_VERSION_MINOR = 3 + } + + /** + * 앱 버전에 따라 적절한 전략을 선택합니다. + * + * @param appVersion 앱 버전 (X-App-Version 헤더) + * @return 선택된 전략 + */ + fun resolveStrategy(appVersion: String?): QuestionRecommendStrategy { + log.debug { "질문 추천 전략 선택 시작 - appVersion: $appVersion" } + + return when { + isNewApp(appVersion) -> { + log.info { "CategoryBasedQuestionStrategy 선택 - appVersion: $appVersion" } + categoryBasedStrategy + } + else -> { + log.info { "LegacyRandomQuestionStrategy 선택 - appVersion: ${appVersion ?: "null"}" } + legacyRandomStrategy + } + } + } + + /** + * 1.3.0 이상이면 신규 앱으로 간주 + */ + fun isNewApp(version: String?): Boolean { + if (version == null) { + log.debug { "앱 버전 null → 구버전으로 간주" } + return false + } + + return try { + val parts = version.split(".") + val major = parts.getOrNull(0)?.toIntOrNull() ?: 0 + val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0 + + val isNew = major > CATEGORY_FEATURE_VERSION_MAJOR || + (major == CATEGORY_FEATURE_VERSION_MAJOR && minor >= CATEGORY_FEATURE_VERSION_MINOR) + + log.debug { "앱 버전 파싱: $version → major=$major, minor=$minor, isNew=$isNew" } + isNew + } catch (e: Exception) { + log.warn(e) { "앱 버전 파싱 실패: $version → 구버전으로 간주" } + false + } + } +} diff --git a/src/main/kotlin/codel/chat/presentation/ChatController.kt b/src/main/kotlin/codel/chat/presentation/ChatController.kt index f7789514..3595b2ec 100644 --- a/src/main/kotlin/codel/chat/presentation/ChatController.kt +++ b/src/main/kotlin/codel/chat/presentation/ChatController.kt @@ -1,12 +1,17 @@ package codel.chat.presentation import codel.chat.business.ChatService +import codel.chat.business.strategy.QuestionRecommendStrategyResolver import codel.chat.presentation.request.CreateChatRoomRequest import codel.chat.presentation.request.ChatLogRequest import codel.chat.presentation.request.ChatSendRequest +import codel.chat.presentation.request.QuestionRecommendRequest import codel.chat.presentation.response.ChatResponse import codel.chat.presentation.response.ChatRoomEventType import codel.chat.presentation.response.ChatRoomResponse +import codel.chat.presentation.response.QuestionRecommendResponseV2 +import codel.chat.presentation.response.QuestionSendResult +import codel.chat.presentation.response.SavedChatDto import codel.chat.presentation.swagger.ChatControllerSwagger import codel.config.Loggable import codel.config.argumentresolver.LoginMember @@ -23,6 +28,7 @@ import org.springframework.web.bind.annotation.* class ChatController( private val chatService: ChatService, private val messagingTemplate: SimpMessagingTemplate, + private val strategyResolver: QuestionRecommendStrategyResolver ) : ChatControllerSwagger, Loggable { @GetMapping("/v1/chatrooms") override fun getChatRooms( @@ -67,28 +73,68 @@ class ChatController( return ResponseEntity.noContent().build() } + /** + * 질문 추천 API (버전 분기) + * + * - 1.3.0 이상: 카테고리 기반 질문 추천 (CategoryBasedQuestionStrategy) + * - 1.3.0 미만: 기존 랜덤 질문 추천 (LegacyRandomQuestionStrategy) + */ @PostMapping("/v1/chatroom/{chatRoomId}/questions/random") override fun sendRandomQuestion( @LoginMember requester: Member, - @PathVariable chatRoomId: Long - ): ResponseEntity { - val result = chatService.sendRandomQuestion(chatRoomId, requester) - - // 1. 채팅방 실시간 메시지 전송 (채팅방에 있는 사용자들에게) + @PathVariable chatRoomId: Long, + @RequestHeader(value = "X-App-Version", required = false) appVersion: String?, + @RequestBody(required = false) request: QuestionRecommendRequest? + ): ResponseEntity { + log.info { "질문 추천 요청 - chatRoomId: $chatRoomId, appVersion: $appVersion, category: ${request?.category}" } + + val strategy = strategyResolver.resolveStrategy(appVersion) + val response = strategy.recommendQuestion(chatRoomId, requester, request ?: QuestionRecommendRequest()) + + // 응답 타입에 따라 WebSocket 메시지 전송 및 HTTP 응답 처리 + return when (val body = response.body) { + is QuestionRecommendResponseV2 -> { + if (body.success && body.chat != null) { + sendQuestionWebSocketMessages(chatRoomId, requester, body.chat) + } + // Map으로 직접 응답 구성 (SavedChatDto.partner 직렬화 시 LazyInitializationException 방지) + ResponseEntity.ok(mapOf( + "success" to body.success, + "question" to body.question, + "chat" to body.chat?.chatResponse, + "exhaustedMessage" to body.exhaustedMessage + )) + } + is QuestionSendResult -> { + sendQuestionWebSocketMessages(chatRoomId, requester, body) + ResponseEntity.ok(body.chatResponse) + } + else -> response + } + } + + private fun sendQuestionWebSocketMessages(chatRoomId: Long, requester: Member, result: QuestionSendResult) { messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", result.chatResponse) - - // 2. 발송자에게는 본인용 채팅방 응답 전송 messagingTemplate.convertAndSend( "/sub/v1/chatroom/member/${requester.getIdOrThrow()}", - result.requesterChatRoomResponse, + result.requesterChatRoomResponse ) - - // 3. 상대방에게는 읽지 않은 수가 증가된 채팅방 응답 전송 messagingTemplate.convertAndSend( "/sub/v1/chatroom/member/${result.partner.getIdOrThrow()}", - result.partnerChatRoomResponse, + result.partnerChatRoomResponse + ) + } + + private fun sendQuestionWebSocketMessages(chatRoomId: Long, requester: Member, chat: SavedChatDto) { + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", chat.chatResponse) + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.getIdOrThrow()}", + chat.requesterChatRoomResponse + ) + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${chat.partner.getIdOrThrow()}", + chat.partnerChatRoomResponse ) - return ResponseEntity.ok(result.chatResponse) } @PostMapping("/v1/chatroom/{chatRoomId}/chat") diff --git a/src/main/kotlin/codel/chat/presentation/request/QuestionRecommendRequest.kt b/src/main/kotlin/codel/chat/presentation/request/QuestionRecommendRequest.kt new file mode 100644 index 00000000..8cbba558 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/request/QuestionRecommendRequest.kt @@ -0,0 +1,10 @@ +package codel.chat.presentation.request + +import codel.question.domain.QuestionCategory +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "질문 추천 요청") +data class QuestionRecommendRequest( + @Schema(description = "선택한 카테고리 (1.3.0 이상에서 필수)", required = false) + val category: QuestionCategory? = null +) diff --git a/src/main/kotlin/codel/chat/presentation/response/QuestionRecommendResponseV2.kt b/src/main/kotlin/codel/chat/presentation/response/QuestionRecommendResponseV2.kt new file mode 100644 index 00000000..8d4b5ae5 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/QuestionRecommendResponseV2.kt @@ -0,0 +1,35 @@ +package codel.chat.presentation.response + +import codel.question.presentation.response.QuestionResponse +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "질문 추천 응답 (1.3.0 이상)") +data class QuestionRecommendResponseV2( + @Schema(description = "추천 성공 여부") + val success: Boolean, + + @Schema(description = "추천된 질문 (소진 시 null)") + val question: QuestionResponse?, + + @Schema(description = "생성된 채팅 메시지") + val chat: SavedChatDto?, + + @Schema(description = "소진 안내 메시지 (소진 시에만)") + val exhaustedMessage: String? +) { + companion object { + fun success(question: QuestionResponse, chat: SavedChatDto) = QuestionRecommendResponseV2( + success = true, + question = question, + chat = chat, + exhaustedMessage = null + ) + + fun exhausted() = QuestionRecommendResponseV2( + success = false, + question = null, + chat = null, + exhaustedMessage = "이 채팅방에서는 해당 카테고리 질문을 모두 사용했어요. 다른 카테고리에서 새로운 질문을 추천받아 보세요." + ) + } +} diff --git a/src/main/kotlin/codel/chat/presentation/swagger/ChatControllerSwagger.kt b/src/main/kotlin/codel/chat/presentation/swagger/ChatControllerSwagger.kt index e8325840..5bfc7b3c 100644 --- a/src/main/kotlin/codel/chat/presentation/swagger/ChatControllerSwagger.kt +++ b/src/main/kotlin/codel/chat/presentation/swagger/ChatControllerSwagger.kt @@ -2,6 +2,7 @@ package codel.chat.presentation.swagger import codel.chat.presentation.request.CreateChatRoomRequest import codel.chat.presentation.request.ChatLogRequest +import codel.chat.presentation.request.QuestionRecommendRequest import codel.chat.presentation.response.ChatResponse import codel.chat.presentation.response.ChatRoomResponse import codel.question.presentation.response.QuestionResponse @@ -19,6 +20,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestParam @Tag(name = "Chat", description = "채팅 관련 API") @@ -127,20 +129,42 @@ interface ChatControllerSwagger { ): ResponseEntity @Operation( - summary = "랜덤 질문 전송", - description = "채팅방에 시스템이 추천하는 랜덤 질문을 전송합니다." + summary = "질문 추천", + description = """ + 채팅방에 질문을 추천합니다. + + **버전 분기 동작:** + - 1.3.0 이상: 카테고리 기반 질문 추천 (request body의 category 필수) + - 1.3.0 미만 또는 헤더 없음: 기존 랜덤 질문 추천 + + **채팅방 카테고리 (1.3.0+):** + - VALUES: 가치관 코드 (A/B 그룹 적용) + - TENSION_UP: 텐션업 코드 (랜덤) + - IF: 만약에 코드 (A/B 그룹 적용) + - SECRET: 비밀 코드 (A/B 그룹 적용, 19+) + + **A/B 그룹 정책:** + - A그룹 질문을 우선 추천하고, 소진되면 B그룹 질문 추천 + - 텐션업 코드는 그룹 구분 없이 랜덤 추천 + """ ) @ApiResponses( value = [ - ApiResponse(responseCode = "200", description = "성공적으로 랜덤 질문 전송"), - ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), - ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ApiResponse(responseCode = "200", description = "질문 추천 성공 또는 소진"), + ApiResponse(responseCode = "400", description = "카테고리 미선택 또는 잘못된 카테고리 (1.3.0+)"), + ApiResponse(responseCode = "403", description = "채팅방 접근 권한 없음"), + ApiResponse(responseCode = "404", description = "채팅방을 찾을 수 없음"), + ApiResponse(responseCode = "500", description = "서버 내부 오류") ] ) fun sendRandomQuestion( @Parameter(hidden = true) @LoginMember requester: Member, - @PathVariable chatRoomId: Long - ): ResponseEntity + @Parameter(description = "채팅방 ID", required = true, example = "123") + @PathVariable chatRoomId: Long, + @Parameter(description = "앱 버전 (1.3.0 이상에서 카테고리 기반 추천)", required = false, example = "1.3.0") + @RequestHeader(value = "X-App-Version", required = false) appVersion: String?, + @RequestBody(required = false) request: QuestionRecommendRequest? + ): ResponseEntity @Operation( summary = "채팅방 대화 종료", diff --git a/src/main/kotlin/codel/question/business/QuestionRecommendationResult.kt b/src/main/kotlin/codel/question/business/QuestionRecommendationResult.kt new file mode 100644 index 00000000..083f3a51 --- /dev/null +++ b/src/main/kotlin/codel/question/business/QuestionRecommendationResult.kt @@ -0,0 +1,18 @@ +package codel.question.business + +import codel.question.domain.Question + +/** + * 질문 추천 결과 + */ +sealed class QuestionRecommendationResult { + /** + * 추천 성공 + */ + data class Success(val question: Question) : QuestionRecommendationResult() + + /** + * 질문 소진 (해당 카테고리의 모든 질문이 사용됨) + */ + data object Exhausted : QuestionRecommendationResult() +} diff --git a/src/main/kotlin/codel/question/business/QuestionService.kt b/src/main/kotlin/codel/question/business/QuestionService.kt index 501bd9ef..c903bddb 100644 --- a/src/main/kotlin/codel/question/business/QuestionService.kt +++ b/src/main/kotlin/codel/question/business/QuestionService.kt @@ -3,6 +3,8 @@ package codel.question.business import codel.question.infrastructure.QuestionJpaRepository import codel.question.domain.Question import codel.question.domain.QuestionCategory +import codel.question.domain.QuestionGroup +import codel.question.domain.GroupPolicy import codel.chat.domain.ChatRoomQuestion import codel.chat.infrastructure.ChatRoomQuestionJpaRepository import codel.chat.infrastructure.ChatRoomJpaRepository @@ -59,6 +61,74 @@ class QuestionService( } return questions.random() } + + // ========== 채팅방 질문 추천 (카테고리 기반) ========== + + /** + * 채팅방 질문 추천 (카테고리별 그룹 정책 적용) + * + * @param chatRoomId 채팅방 ID + * @param category 선택한 카테고리 + * @return 추천 결과 (Success 또는 Exhausted) + */ + fun recommendQuestionForChat( + chatRoomId: Long, + category: QuestionCategory + ): QuestionRecommendationResult { + if (!category.isChatCategory()) { + throw IllegalArgumentException("채팅방에서 사용할 수 없는 카테고리입니다: ${category.displayName}") + } + + return when (category.chatGroupPolicy) { + GroupPolicy.RANDOM -> recommendRandom(chatRoomId, category) + GroupPolicy.A_THEN_B -> recommendWithGroupPriority(chatRoomId, category) + GroupPolicy.NONE -> throw IllegalStateException("채팅방용 카테고리에 NONE 정책은 허용되지 않습니다.") + } + } + + /** + * A그룹 우선 → B그룹 순서로 추천 + */ + private fun recommendWithGroupPriority( + chatRoomId: Long, + category: QuestionCategory + ): QuestionRecommendationResult { + // 1. A그룹에서 미사용 질문 조회 + val groupAQuestions = questionJpaRepository + .findUnusedQuestionsByChatRoomAndCategoryAndGroup(chatRoomId, category, QuestionGroup.A) + + if (groupAQuestions.isNotEmpty()) { + return QuestionRecommendationResult.Success(groupAQuestions.random()) + } + + // 2. A그룹 소진 시 B그룹에서 조회 + val groupBQuestions = questionJpaRepository + .findUnusedQuestionsByChatRoomAndCategoryAndGroup(chatRoomId, category, QuestionGroup.B) + + if (groupBQuestions.isNotEmpty()) { + return QuestionRecommendationResult.Success(groupBQuestions.random()) + } + + // 3. 모두 소진 + return QuestionRecommendationResult.Exhausted + } + + /** + * 그룹 구분 없이 랜덤 추천 + */ + private fun recommendRandom( + chatRoomId: Long, + category: QuestionCategory + ): QuestionRecommendationResult { + val questions = questionJpaRepository + .findUnusedQuestionsByChatRoomAndCategory(chatRoomId, category) + + return if (questions.isNotEmpty()) { + QuestionRecommendationResult.Success(questions.random()) + } else { + QuestionRecommendationResult.Exhausted + } + } /** * 질문을 사용된 것으로 표시 @@ -99,7 +169,22 @@ class QuestionService( val categoryEnum = if (category.isNullOrBlank()) null else QuestionCategory.valueOf(category) return questionJpaRepository.findAllWithFilter(keyword, categoryEnum, isActive, pageable) } - + + /** + * 필터 조건으로 질문 목록 조회 (그룹 포함) + */ + fun findQuestionsWithFilterV2( + keyword: String?, + category: String?, + questionGroup: String?, + isActive: Boolean?, + pageable: Pageable + ): Page { + val categoryEnum = if (category.isNullOrBlank()) null else QuestionCategory.valueOf(category) + val groupEnum = if (questionGroup.isNullOrBlank()) null else QuestionGroup.valueOf(questionGroup) + return questionJpaRepository.findAllWithFilterV2(keyword, categoryEnum, groupEnum, isActive, pageable) + } + /** * 새 질문 생성 */ @@ -118,7 +203,28 @@ class QuestionService( ) return questionJpaRepository.save(question) } - + + /** + * 새 질문 생성 (그룹 포함) + */ + @Transactional + fun createQuestionV2( + content: String, + category: QuestionCategory, + questionGroup: QuestionGroup, + description: String?, + isActive: Boolean + ): Question { + val question = Question( + content = content, + category = category, + questionGroup = questionGroup, + description = description, + isActive = isActive + ) + return questionJpaRepository.save(question) + } + /** * 질문 수정 */ @@ -131,12 +237,35 @@ class QuestionService( isActive: Boolean ): Question { val question = findQuestionById(questionId) - + + question.updateContent(content) + question.updateCategory(category) + question.updateDescription(description) + question.updateIsActive(isActive) + + return questionJpaRepository.save(question) + } + + /** + * 질문 수정 (그룹 포함) + */ + @Transactional + fun updateQuestionV2( + questionId: Long, + content: String, + category: QuestionCategory, + questionGroup: QuestionGroup, + description: String?, + isActive: Boolean + ): Question { + val question = findQuestionById(questionId) + question.updateContent(content) question.updateCategory(category) + question.updateQuestionGroup(questionGroup) question.updateDescription(description) question.updateIsActive(isActive) - + return questionJpaRepository.save(question) } diff --git a/src/main/kotlin/codel/question/domain/GroupPolicy.kt b/src/main/kotlin/codel/question/domain/GroupPolicy.kt new file mode 100644 index 00000000..597da1c9 --- /dev/null +++ b/src/main/kotlin/codel/question/domain/GroupPolicy.kt @@ -0,0 +1,21 @@ +package codel.question.domain + +/** + * 채팅방 질문 추천 시 그룹 정책 + */ +enum class GroupPolicy { + /** + * 그룹 정책 없음 (회원가입 전용 카테고리) + */ + NONE, + + /** + * A그룹 우선 → B그룹 순서로 추천 + */ + A_THEN_B, + + /** + * 그룹 구분 없이 랜덤 추천 + */ + RANDOM +} diff --git a/src/main/kotlin/codel/question/domain/Question.kt b/src/main/kotlin/codel/question/domain/Question.kt index a367ead1..46b6a708 100644 --- a/src/main/kotlin/codel/question/domain/Question.kt +++ b/src/main/kotlin/codel/question/domain/Question.kt @@ -7,19 +7,23 @@ import jakarta.persistence.* class Question( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, - + @Column(nullable = false, length = 500) var content: String, @Enumerated(EnumType.STRING) @Column(nullable = false, length = 100) var category: QuestionCategory, - + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var questionGroup: QuestionGroup = QuestionGroup.RANDOM, + @Column(nullable = false) var isActive: Boolean = true, - + @Column(nullable = true, length = 1000) - var description: String? = null // 질문 설명 + var description: String? = null ) : BaseTimeEntity() { fun getIdOrThrow(): Long = id ?: throw IllegalStateException("질문이 존재하지 않습니다.") @@ -35,7 +39,11 @@ class Question( fun updateCategory(newCategory: QuestionCategory) { this.category = newCategory } - + + fun updateQuestionGroup(newQuestionGroup: QuestionGroup) { + this.questionGroup = newQuestionGroup + } + fun updateDescription(newDescription: String?) { this.description = newDescription } diff --git a/src/main/kotlin/codel/question/domain/QuestionCategory.kt b/src/main/kotlin/codel/question/domain/QuestionCategory.kt index 973c50db..76a59a7d 100644 --- a/src/main/kotlin/codel/question/domain/QuestionCategory.kt +++ b/src/main/kotlin/codel/question/domain/QuestionCategory.kt @@ -7,35 +7,128 @@ enum class QuestionCategory( @Schema(description = "카테고리 표시명") val displayName: String, @Schema(description = "카테고리 상세 설명") - val description: String + val description: String, + @Schema(description = "회원가입에서 사용 여부") + val usedInSignup: Boolean, + @Schema(description = "채팅방에서 사용 여부") + val usedInChat: Boolean, + @Schema(description = "채팅방 그룹 정책") + val chatGroupPolicy: GroupPolicy ) { - @Schema(description = "가치관 관련 질문") - VALUES("가치관", "인생 가치관·성향"), - + // 회원가입 전용 + @Schema(description = "가치관 관련 질문 (회원가입 전용)") + VALUES( + displayName = "가치관", + description = "인생 가치관·성향", + usedInSignup = true, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), + + // 회원가입 전용 @Schema(description = "취향 관련 질문") - FAVORITE("취향", "취향·관심사·콘텐츠"), - + FAVORITE( + displayName = "favorite", + description = "취향·관심사·콘텐츠", + usedInSignup = true, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), + @Schema(description = "현재 상태 관련 질문") - CURRENT_ME("요즘 나", "최근 상태·몰입한 것"), - + CURRENT_ME( + displayName = "요즘 나", + description = "최근 상태·몰입한 것", + usedInSignup = true, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), + @Schema(description = "데이트/관계 관련 질문") - DATE("데이트", "사람 대할 때 나의 방식"), - + DATE( + displayName = "데이트", + description = "사람 대할 때 나의 방식", + usedInSignup = true, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), + @Schema(description = "추억/경험 관련 질문") - MEMORY("추억", "감동·전환점·경험 공유"), - + MEMORY( + displayName = "추억", + description = "감동·전환점·경험 공유", + usedInSignup = true, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), + @Schema(description = "대화 주제 관련 질문") - WANT_TALK("이런 대화 해보고 싶어", "나누고 싶은 진짜 이야기"), - - @Schema(description = "밸런스 게임 관련 질문") - BALANCE_ONE("하나만", "가벼운 밸런스 게임"), + WANT_TALK( + displayName = "이런대화해보고싶어", + description = "나누고 싶은 진짜 이야기", + usedInSignup = true, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), + + @Schema(description = "밸런스 게임 관련 질문 (레거시)") + BALANCE_ONE( + displayName = "하나만", + description = "가벼운 밸런스 게임", + usedInSignup = false, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), - @Schema(description = "가정 상황 관련 질문") - IF("만약에", "가상의 상황·선택 질문"); + // 채팅방 전용 + @Schema(description = "가치관 코드 - 가치관으로 서로의 성격 코드 알아가기") + VALUES_CODE( + displayName = "가치관 코드", + description = "가치관으로 서로의 성격 코드 알아가기", + usedInSignup = false, + usedInChat = true, + chatGroupPolicy = GroupPolicy.A_THEN_B + ), + + @Schema(description = "텐션업 코드 - 가벼운 선택 질문") + TENSION_UP( + displayName = "텐션업 코드", + description = "가벼운 선택 질문으로 텐션은 올리고 부담은 줄이기", + usedInSignup = false, + usedInChat = true, + chatGroupPolicy = GroupPolicy.RANDOM + ), + + @Schema(description = "만약에 코드 - 가정 상황 질문") + IF( + displayName = "만약에 코드", + description = "상황을 가정하며 자연스럽게 서로의 성격 코드 알아가기", + usedInSignup = false, + usedInChat = true, + chatGroupPolicy = GroupPolicy.A_THEN_B + ), + + @Schema(description = "비밀 코드(19+) - 민감한 주제 질문") + SECRET( + displayName = "비밀 코드(19+)", + description = "먼저 묻기 민망한 취향과 텐션을 조심스럽고 솔직하게", + usedInSignup = false, + usedInChat = true, + chatGroupPolicy = GroupPolicy.A_THEN_B + ); + + fun isChatCategory(): Boolean = usedInChat + fun isSignupCategory(): Boolean = usedInSignup companion object { fun fromString(category: String?): QuestionCategory? { - return values().find { it.name.equals(category, ignoreCase = true) } + return entries.find { it.name.equals(category, ignoreCase = true) } } + + fun getSignupCategories(): List = + entries.filter { it.usedInSignup } + + fun getChatCategories(): List = + entries.filter { it.usedInChat } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/codel/question/domain/QuestionGroup.kt b/src/main/kotlin/codel/question/domain/QuestionGroup.kt new file mode 100644 index 00000000..5133b9c7 --- /dev/null +++ b/src/main/kotlin/codel/question/domain/QuestionGroup.kt @@ -0,0 +1,24 @@ +package codel.question.domain + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "질문 그룹", enumAsRef = true) +enum class QuestionGroup( + @Schema(description = "그룹 표시명") + val displayName: String +) { + @Schema(description = "A그룹 - 가벼운/진입용 질문") + A("A그룹"), + + @Schema(description = "B그룹 - 깊이 있는/무게감 있는 질문") + B("B그룹"), + + @Schema(description = "그룹 구분 없음") + RANDOM("랜덤"); + + companion object { + fun fromString(group: String?): QuestionGroup? { + return entries.find { it.name.equals(group, ignoreCase = true) } + } + } +} diff --git a/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt b/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt index 1ffa95f9..3f7f9f7c 100644 --- a/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt +++ b/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt @@ -3,6 +3,7 @@ package codel.question.infrastructure import codel.chat.domain.ChatRoomQuestion import codel.question.domain.Question import codel.question.domain.QuestionCategory +import codel.question.domain.QuestionGroup import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -17,11 +18,47 @@ interface QuestionJpaRepository : JpaRepository { fun findActiveQuestions(): List @Query(""" - SELECT q FROM Question q - WHERE q.isActive = true - AND q.category NOT IN ('IF', 'BALANCE_ONE') + SELECT q FROM Question q + WHERE q.isActive = true + AND q.category IN ('VALUES', 'FAVORITE', 'DATE', 'MEMORY', 'WANT_TALK') """) fun findActiveQuestionsForSignup(): List + + /** + * 채팅방에서 특정 카테고리의 미사용 질문 조회 (그룹별) + */ + @Query(""" + SELECT q FROM Question q + WHERE q.isActive = true + AND q.category = :category + AND q.questionGroup = :questionGroup + AND q.id NOT IN ( + SELECT crq.question.id FROM ChatRoomQuestion crq + WHERE crq.chatRoom.id = :chatRoomId AND crq.isUsed = true + ) + """) + fun findUnusedQuestionsByChatRoomAndCategoryAndGroup( + @Param("chatRoomId") chatRoomId: Long, + @Param("category") category: QuestionCategory, + @Param("questionGroup") questionGroup: QuestionGroup + ): List + + /** + * 채팅방에서 특정 카테고리의 미사용 질문 조회 (그룹 무관) + */ + @Query(""" + SELECT q FROM Question q + WHERE q.isActive = true + AND q.category = :category + AND q.id NOT IN ( + SELECT crq.question.id FROM ChatRoomQuestion crq + WHERE crq.chatRoom.id = :chatRoomId AND crq.isUsed = true + ) + """) + fun findUnusedQuestionsByChatRoomAndCategory( + @Param("chatRoomId") chatRoomId: Long, + @Param("category") category: QuestionCategory + ): List @Query(""" SELECT q FROM Question q @@ -47,6 +84,25 @@ interface QuestionJpaRepository : JpaRepository { pageable: Pageable ): Page + /** + * 관리자: 카테고리/그룹/상태 필터 조회 + */ + @Query(""" + SELECT q FROM Question q + WHERE (:keyword IS NULL OR :keyword = '' OR q.content LIKE CONCAT('%', :keyword, '%') OR q.description LIKE CONCAT('%', :keyword, '%')) + AND (:category IS NULL OR q.category = :category) + AND (:questionGroup IS NULL OR q.questionGroup = :questionGroup) + AND (:isActive IS NULL OR q.isActive = :isActive) + ORDER BY q.createdAt DESC + """) + fun findAllWithFilterV2( + @Param("keyword") keyword: String?, + @Param("category") category: QuestionCategory?, + @Param("questionGroup") questionGroup: QuestionGroup?, + @Param("isActive") isActive: Boolean?, + pageable: Pageable + ): Page + /** * 채팅방 질문 통계 - 질문별 사용 횟수 (상위 N개) * 초기 질문(isInitial=true) 제외, 질문하기 버튼 클릭으로 추가된 질문만 집계 diff --git a/src/main/kotlin/codel/recommendation/business/AgePreferenceResolver.kt b/src/main/kotlin/codel/recommendation/business/AgePreferenceResolver.kt new file mode 100644 index 00000000..eaa4c557 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/AgePreferenceResolver.kt @@ -0,0 +1,61 @@ +package codel.recommendation.business + +import codel.member.domain.Member +import codel.recommendation.domain.AgePreference +import codel.recommendation.domain.RecommendationConfig +import org.springframework.stereotype.Component + +/** + * 나이 선호도 설정을 조회하는 Resolver + * + * 현재는 서비스 전역 설정(Config)에서 가져오지만, + * 미래에는 회원별 개인 설정으로 확장 가능 + * + * @see AgePreference + */ +@Component +class AgePreferenceResolver( + private val recommendationConfig: RecommendationConfig +) { + + /** + * 특정 회원의 나이 선호도 설정을 조회 + * + * Phase 1: Config 기본값 반환 + * Phase 2 (미래): 회원 설정이 있으면 우선 사용, 없으면 Config fallback + * + * @param member 대상 회원 + * @return 해당 회원에게 적용할 나이 선호도 설정 + */ + fun resolve(member: Member): AgePreference { + // Phase 1: Config 기본값 반환 + // TODO: Phase 2에서 회원별 설정 테이블 조회 로직 추가 + // val memberPreference = memberPreferenceRepository.findByMemberId(member.id) + // if (memberPreference != null) { + // return AgePreference( + // preferredMaxDiff = memberPreference.agePreferredMaxDiff, + // cutoffDiff = memberPreference.ageCutoffDiff, + // allowCutoffWhenInsufficient = memberPreference.ageAllowCutoffWhenInsufficient + // ) + // } + + return AgePreference( + preferredMaxDiff = recommendationConfig.agePreferredMaxDiff, + cutoffDiff = recommendationConfig.ageCutoffDiff, + allowCutoffWhenInsufficient = recommendationConfig.ageAllowCutoffWhenInsufficient + ) + } + + /** + * 기본 나이 선호도 설정 조회 (회원 정보 없이) + * + * @return Config 기본 나이 선호도 설정 + */ + fun resolveDefault(): AgePreference { + return AgePreference( + preferredMaxDiff = recommendationConfig.agePreferredMaxDiff, + cutoffDiff = recommendationConfig.ageCutoffDiff, + allowCutoffWhenInsufficient = recommendationConfig.ageAllowCutoffWhenInsufficient + ) + } +} diff --git a/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt b/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt index 1eae8a8c..60a5edd1 100644 --- a/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt +++ b/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt @@ -31,7 +31,8 @@ class CodeTimeService( private val bucketService: RecommendationBucketService, private val historyService: RecommendationHistoryService, private val exclusionService: RecommendationExclusionService, - private val timeZoneService: TimeZoneService + private val timeZoneService: TimeZoneService, + private val agePreferenceResolver: AgePreferenceResolver ) : Loggable { /** @@ -257,22 +258,35 @@ class CodeTimeService( val excludeIds = getExcludeIdsForCodeTime(user) + // 나이 정보 조회 + val userAge = try { + userProfile.getAge() + } catch (e: Exception) { + log.warn { "사용자 나이 정보 조회 실패 - userId: ${user.getIdOrThrow()}, 나이 필터링 없이 진행" } + null + } + + val agePreference = agePreferenceResolver.resolve(user) + log.debug { "코드타임 생성 - userId: ${user.getIdOrThrow()}, " + - "timeSlot: $timeSlot, region: $userMainRegion-$userSubRegion, " + - "excludeCount: ${excludeIds.size}개" + "timeSlot: $timeSlot, region: $userMainRegion-$userSubRegion, userAge: $userAge, " + + "agePreference: preferredMax=${agePreference.preferredMaxDiff}, cutoff=${agePreference.cutoffDiff}, " + + "excludeCount: ${excludeIds.size}개" } val candidates = bucketService.getCandidatesByBucket( userMainRegion = userMainRegion, userSubRegion = userSubRegion ?: "", excludeIds = excludeIds, - requiredCount = config.codeTimeCount + requiredCount = config.codeTimeCount, + userAge = userAge, + agePreference = agePreference ) log.info { "코드타임 후보자 선정 - userId: ${user.getIdOrThrow()}, " + - "timeSlot: $timeSlot, requested: ${config.codeTimeCount}개, actual: ${candidates.size}개" + "timeSlot: $timeSlot, requested: ${config.codeTimeCount}개, actual: ${candidates.size}개" } return candidates diff --git a/src/main/kotlin/codel/recommendation/business/DailyCodeMatchingService.kt b/src/main/kotlin/codel/recommendation/business/DailyCodeMatchingService.kt index 9d9eac8c..6b64d696 100644 --- a/src/main/kotlin/codel/recommendation/business/DailyCodeMatchingService.kt +++ b/src/main/kotlin/codel/recommendation/business/DailyCodeMatchingService.kt @@ -25,7 +25,8 @@ class DailyCodeMatchingService( private val bucketService: RecommendationBucketService, private val historyService: RecommendationHistoryService, private val exclusionService: RecommendationExclusionService, - private val timeZoneService: TimeZoneService + private val timeZoneService: TimeZoneService, + private val agePreferenceResolver: AgePreferenceResolver ) : Loggable { /** @@ -167,22 +168,36 @@ class DailyCodeMatchingService( log.info { "코드매칭 중 제외된 아이디 전부 조회 :::: " + excludeIds.joinToString(",") } + // 3. 나이 정보 조회 + val userAge = try { + userProfile.getAge() + } catch (e: Exception) { + log.warn { "사용자 나이 정보 조회 실패 - userId: ${user.getIdOrThrow()}, 나이 필터링 없이 진행" } + null + } + + val agePreference = agePreferenceResolver.resolve(user) + log.debug { "오늘의 코드매칭 생성 - userId: ${user.getIdOrThrow()}, " + - "region: $userMainRegion-$userSubRegion, excludeCount: ${excludeIds.size}개" + "region: $userMainRegion-$userSubRegion, userAge: $userAge, " + + "agePreference: preferredMax=${agePreference.preferredMaxDiff}, cutoff=${agePreference.cutoffDiff}, " + + "excludeCount: ${excludeIds.size}개" } - // 3. 버킷 정책으로 후보자 조회 + // 4. 버킷 정책으로 후보자 조회 (나이 우선순위 적용) val candidates = bucketService.getCandidatesByBucket( userMainRegion = userMainRegion, userSubRegion = userSubRegion ?: "", excludeIds = excludeIds, - requiredCount = config.dailyCodeCount + requiredCount = config.dailyCodeCount, + userAge = userAge, + agePreference = agePreference ) log.info { "오늘의 코드매칭 후보자 선정 - userId: ${user.getIdOrThrow()}, " + - "requested: ${config.dailyCodeCount}개, actual: ${candidates.size}개" + "requested: ${config.dailyCodeCount}개, actual: ${candidates.size}개" } return candidates diff --git a/src/main/kotlin/codel/recommendation/business/RecommendationBucketService.kt b/src/main/kotlin/codel/recommendation/business/RecommendationBucketService.kt index bbb338ed..8517a284 100644 --- a/src/main/kotlin/codel/recommendation/business/RecommendationBucketService.kt +++ b/src/main/kotlin/codel/recommendation/business/RecommendationBucketService.kt @@ -2,11 +2,14 @@ package codel.recommendation.business import codel.member.domain.Member import codel.member.infrastructure.MemberJpaRepository +import codel.recommendation.domain.AgePreference +import codel.recommendation.domain.AgeTier import codel.recommendation.domain.RecommendationConfig import org.springframework.stereotype.Service import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.transaction.annotation.Transactional import org.hibernate.Hibernate +import kotlin.math.abs /** * 추천 시스템의 4단계 버킷 정책을 구현하는 서비스 @@ -28,68 +31,315 @@ class RecommendationBucketService( /** * 4단계 버킷 정책에 따라 추천 후보자들을 추출합니다. - * 상위 버킷에서 부족한 인원을 하위 버킷에서 보충합니다. - * + * 나이 우선순위 정책이 적용됩니다. + * + * 버킷 정책 우선순위: B1 → B2 → B3 → B4 + * 나이 정책: + * - Phase 1: 각 버킷에서 선호 범위(0~5살) 후보만 선발 + * - Phase 2: 부족 시 컷오프 대상(6살+) 허용 (설정에 따라) + * * @param userMainRegion 사용자의 메인 지역 (예: 서울, 경기) * @param userSubRegion 사용자의 서브 지역 (예: 강남, 분당) * @param excludeIds 제외할 사용자 ID 목록 (자신, 차단, 중복 방지 대상) * @param requiredCount 필요한 추천 인원수 - * @return 버킷 정책에 따라 정렬된 추천 후보자 목록 + * @param userAge 사용자의 나이 (null이면 나이 필터링 없이 기존 로직 적용) + * @param agePreference 나이 선호도 설정 (null이면 기본값 사용) + * @return 버킷 및 나이 정책에 따라 정렬된 추천 후보자 목록 */ fun getCandidatesByBucket( + userMainRegion: String, + userSubRegion: String, + excludeIds: Set, + requiredCount: Int, + userAge: Int? = null, + agePreference: AgePreference? = null + ): List { + + // 나이 정보가 없으면 기존 로직 사용 + if (userAge == null) { + return getCandidatesByBucketLegacy(userMainRegion, userSubRegion, excludeIds, requiredCount) + } + + val agePref = agePreference ?: AgePreference.default() + + logger.info { + "버킷 정책 시작 (나이 우선순위 적용) - userRegion: $userMainRegion-$userSubRegion, " + + "userAge: $userAge, excludeIds: ${excludeIds.size}개, requiredCount: $requiredCount, " + + "agePreference: preferredMax=${agePref.preferredMaxDiff}, cutoff=${agePref.cutoffDiff}" + } + + val results = mutableListOf() + val usedIds = excludeIds.toMutableSet() + + // Phase 1: 선호 범위(0~5살) 후보만 찾기 (버킷 점프 적용) + collectPreferredAgeCandidates( + userMainRegion, userSubRegion, userAge, agePref, usedIds, requiredCount, results + ) + + // Phase 2: 부족 시 컷오프 대상(6살+) 허용 + if (results.size < requiredCount && agePref.allowCutoffWhenInsufficient) { + logger.info { "선호 범위 후보 부족 - 컷오프 대상(${agePref.cutoffDiff}살+) 허용" } + + collectCutoffAgeCandidates( + userMainRegion, userSubRegion, userAge, agePref, usedIds, requiredCount, results + ) + } + + logger.info { "버킷 정책 완료 - 최종 선택: ${results.size}명 / 요청: ${requiredCount}명" } + + return results + } + + /** + * Phase 1: 선호 범위(0~5살) 후보 수집 + * 각 버킷에서 선호 범위 내의 후보만 선발하며, 버킷 점프 적용 + */ + private fun collectPreferredAgeCandidates( + userMainRegion: String, + userSubRegion: String, + userAge: Int, + agePref: AgePreference, + usedIds: MutableSet, + requiredCount: Int, + results: MutableList + ) { + // B1: 동일 subRegion - 선호 범위만 + if (results.size < requiredCount) { + val b1Candidates = getBucket1Candidates(userMainRegion, userSubRegion, usedIds) + val b1Preferred = filterAndSortByAge(b1Candidates, userAge, agePref, preferredOnly = true) + val b1Selected = b1Preferred.take(requiredCount - results.size) + results.addAll(b1Selected) + usedIds.addAll(b1Selected.mapNotNull { it.id }) + + logger.info { + "B1 버킷 (동일 subRegion, 선호 범위): ${b1Selected.size}명 선택 " + + "(전체: ${b1Candidates.size}명, 선호 범위: ${b1Preferred.size}명)" + } + } + + // B2: 동일 mainRegion 내 다른 subRegion - 선호 범위만 + if (results.size < requiredCount) { + val b2Candidates = getBucket2Candidates(userMainRegion, userSubRegion, usedIds) + val b2Preferred = filterAndSortByAge(b2Candidates, userAge, agePref, preferredOnly = true) + val b2Selected = b2Preferred.take(requiredCount - results.size) + results.addAll(b2Selected) + usedIds.addAll(b2Selected.mapNotNull { it.id }) + + logger.info { + "B2 버킷 (동일 mainRegion, 선호 범위): ${b2Selected.size}명 선택 " + + "(전체: ${b2Candidates.size}명, 선호 범위: ${b2Preferred.size}명)" + } + } + + // B3: 인접 mainRegion - 선호 범위만 + if (results.size < requiredCount) { + val b3Candidates = getBucket3Candidates(userMainRegion, usedIds) + val b3Preferred = filterAndSortByAge(b3Candidates, userAge, agePref, preferredOnly = true) + val b3Selected = b3Preferred.take(requiredCount - results.size) + results.addAll(b3Selected) + usedIds.addAll(b3Selected.mapNotNull { it.id }) + + logger.info { + "B3 버킷 (인접 mainRegion, 선호 범위): ${b3Selected.size}명 선택 " + + "(전체: ${b3Candidates.size}명, 선호 범위: ${b3Preferred.size}명)" + } + } + + // B4: 전국 범위 - 선호 범위만 + if (results.size < requiredCount) { + val b4Candidates = getBucket4Candidates(usedIds) + val b4Preferred = filterAndSortByAge(b4Candidates, userAge, agePref, preferredOnly = true) + val b4Selected = b4Preferred.take(requiredCount - results.size) + results.addAll(b4Selected) + usedIds.addAll(b4Selected.mapNotNull { it.id }) + + logger.info { + "B4 버킷 (전국 범위, 선호 범위): ${b4Selected.size}명 선택 " + + "(전체: ${b4Candidates.size}명, 선호 범위: ${b4Preferred.size}명)" + } + } + } + + /** + * Phase 2: 컷오프 대상(6살+) 후보 수집 + * 선호 범위 후보가 부족할 때 실행 + */ + private fun collectCutoffAgeCandidates( + userMainRegion: String, + userSubRegion: String, + userAge: Int, + agePref: AgePreference, + usedIds: MutableSet, + requiredCount: Int, + results: MutableList + ) { + // B1: 동일 subRegion - 컷오프 대상 + if (results.size < requiredCount) { + val b1Candidates = getBucket1Candidates(userMainRegion, userSubRegion, usedIds) + val b1Cutoff = filterAndSortByAge(b1Candidates, userAge, agePref, preferredOnly = false) + .filter { agePref.isCutoff(abs(userAge - getAgeOrDefault(it))) } + val b1Selected = b1Cutoff.take(requiredCount - results.size) + results.addAll(b1Selected) + usedIds.addAll(b1Selected.mapNotNull { it.id }) + + if (b1Selected.isNotEmpty()) { + logger.info { "B1 버킷 (동일 subRegion, 컷오프): ${b1Selected.size}명 선택" } + } + } + + // B2~B4도 동일하게 컷오프 대상 수집 + if (results.size < requiredCount) { + val b2Candidates = getBucket2Candidates(userMainRegion, userSubRegion, usedIds) + val b2Cutoff = filterAndSortByAge(b2Candidates, userAge, agePref, preferredOnly = false) + .filter { agePref.isCutoff(abs(userAge - getAgeOrDefault(it))) } + val b2Selected = b2Cutoff.take(requiredCount - results.size) + results.addAll(b2Selected) + usedIds.addAll(b2Selected.mapNotNull { it.id }) + + if (b2Selected.isNotEmpty()) { + logger.info { "B2 버킷 (동일 mainRegion, 컷오프): ${b2Selected.size}명 선택" } + } + } + + if (results.size < requiredCount) { + val b3Candidates = getBucket3Candidates(userMainRegion, usedIds) + val b3Cutoff = filterAndSortByAge(b3Candidates, userAge, agePref, preferredOnly = false) + .filter { agePref.isCutoff(abs(userAge - getAgeOrDefault(it))) } + val b3Selected = b3Cutoff.take(requiredCount - results.size) + results.addAll(b3Selected) + usedIds.addAll(b3Selected.mapNotNull { it.id }) + + if (b3Selected.isNotEmpty()) { + logger.info { "B3 버킷 (인접 mainRegion, 컷오프): ${b3Selected.size}명 선택" } + } + } + + if (results.size < requiredCount) { + val b4Candidates = getBucket4Candidates(usedIds) + val b4Cutoff = filterAndSortByAge(b4Candidates, userAge, agePref, preferredOnly = false) + .filter { agePref.isCutoff(abs(userAge - getAgeOrDefault(it))) } + val b4Selected = b4Cutoff.take(requiredCount - results.size) + results.addAll(b4Selected) + + if (b4Selected.isNotEmpty()) { + logger.info { "B4 버킷 (전국 범위, 컷오프): ${b4Selected.size}명 선택" } + } + } + } + + /** + * 나이 기준으로 후보 필터링 및 정렬 + * + * 정렬 기준: + * 1. AgeTier (A1 > A2 > A3) + * 2. 같은 Tier 내에서는 ageDiff 작은 순 + * 3. 동일하면 랜덤 + * + * @param candidates 원본 후보 목록 + * @param userAge 사용자 나이 + * @param agePref 나이 선호도 설정 + * @param preferredOnly true면 선호 범위(0~5살)만, false면 전체 + */ + private fun filterAndSortByAge( + candidates: List, + userAge: Int, + agePref: AgePreference, + preferredOnly: Boolean + ): List { + val filtered = if (preferredOnly) { + candidates.filter { member -> + val age = getAgeOrNull(member) ?: return@filter false + agePref.isPreferred(abs(userAge - age)) + } + } else { + candidates.filter { getAgeOrNull(it) != null } + } + + return filtered + .shuffled() // 먼저 랜덤 셔플 (동일 조건일 때 랜덤 효과) + .sortedWith(compareBy( + { AgeTier.from(abs(userAge - getAgeOrDefault(it))).priority }, // Tier 우선 + { abs(userAge - getAgeOrDefault(it)) } // 같은 Tier 내에서는 ageDiff 작은 순 + )) + } + + /** + * Member의 나이를 안전하게 조회 (null 반환) + */ + private fun getAgeOrNull(member: Member): Int? { + return try { + member.profile?.getAge() + } catch (e: Exception) { + logger.debug { "나이 조회 실패 - memberId: ${member.id}" } + null + } + } + + /** + * Member의 나이를 안전하게 조회 (기본값 반환) + */ + private fun getAgeOrDefault(member: Member, default: Int = Int.MAX_VALUE): Int { + return getAgeOrNull(member) ?: default + } + + /** + * 기존 버킷 정책 (나이 필터링 없음) + * 하위 호환성을 위해 유지 + */ + private fun getCandidatesByBucketLegacy( userMainRegion: String, userSubRegion: String, excludeIds: Set, requiredCount: Int ): List { - - logger.info { "버킷 정책 시작 - userRegion: $userMainRegion-$userSubRegion, excludeIds: ${excludeIds.size}개, requiredCount: $requiredCount" } - + + logger.info { "버킷 정책 시작 (레거시) - userRegion: $userMainRegion-$userSubRegion, excludeIds: ${excludeIds.size}개, requiredCount: $requiredCount" } + val results = mutableListOf() val usedIds = mutableSetOf() usedIds.addAll(excludeIds) - + // B1: 동일 subRegion (최우선) if (results.size < requiredCount) { val b1Candidates = getBucket1Candidates(userMainRegion, userSubRegion, usedIds) val b1Selected = b1Candidates.take(requiredCount - results.size) results.addAll(b1Selected) usedIds.addAll(b1Selected.mapNotNull { it.id }) - + logger.info { "B1 버킷 (동일 subRegion): ${b1Selected.size}명 선택 (전체 후보: ${b1Candidates.size}명)" } } - - // B2: 동일 mainRegion 내 다른 subRegion + + // B2: 동일 mainRegion 내 다른 subRegion if (results.size < requiredCount) { val b2Candidates = getBucket2Candidates(userMainRegion, userSubRegion, usedIds) val b2Selected = b2Candidates.take(requiredCount - results.size) results.addAll(b2Selected) usedIds.addAll(b2Selected.mapNotNull { it.id }) - + logger.info { "B2 버킷 (동일 mainRegion): ${b2Selected.size}명 선택 (전체 후보: ${b2Candidates.size}명)" } } - + // B3: 인접 mainRegion if (results.size < requiredCount) { val b3Candidates = getBucket3Candidates(userMainRegion, usedIds) val b3Selected = b3Candidates.take(requiredCount - results.size) results.addAll(b3Selected) usedIds.addAll(b3Selected.mapNotNull { it.id }) - + logger.info { "B3 버킷 (인접 mainRegion): ${b3Selected.size}명 선택 (전체 후보: ${b3Candidates.size}명)" } } - + // B4: 전국 범위 (최후 보충) if (results.size < requiredCount) { val b4Candidates = getBucket4Candidates(usedIds) val b4Selected = b4Candidates.take(requiredCount - results.size) results.addAll(b4Selected) - + logger.info { "B4 버킷 (전국 범위): ${b4Selected.size}명 선택 (전체 후보: ${b4Candidates.size}명)" } } - + logger.info { "버킷 정책 완료 - 최종 선택: ${results.size}명 / 요청: ${requiredCount}명" } - + return results } diff --git a/src/main/kotlin/codel/recommendation/business/RecommendationConfigService.kt b/src/main/kotlin/codel/recommendation/business/RecommendationConfigService.kt index 868a61d7..579a8a7b 100644 --- a/src/main/kotlin/codel/recommendation/business/RecommendationConfigService.kt +++ b/src/main/kotlin/codel/recommendation/business/RecommendationConfigService.kt @@ -100,7 +100,28 @@ class RecommendationConfigService( fun getAllowDuplicate(): Boolean { return getConfig().allowDuplicate } - + + /** + * 우선 추천 최대 나이 차이 조회 + */ + fun getAgePreferredMaxDiff(): Int { + return getConfig().agePreferredMaxDiff + } + + /** + * 컷오프 기준 나이 차이 조회 + */ + fun getAgeCutoffDiff(): Int { + return getConfig().ageCutoffDiff + } + + /** + * 후보 부족 시 컷오프 대상 허용 여부 조회 + */ + fun getAgeAllowCutoffWhenInsufficient(): Boolean { + return getConfig().ageAllowCutoffWhenInsufficient + } + /** * 설정 업데이트 (캐시 제거) */ @@ -112,38 +133,54 @@ class RecommendationConfigService( codeTimeSlots: List? = null, dailyRefreshTime: String? = null, repeatAvoidDays: Int? = null, - allowDuplicate: Boolean? = null + allowDuplicate: Boolean? = null, + agePreferredMaxDiff: Int? = null, + ageCutoffDiff: Int? = null, + ageAllowCutoffWhenInsufficient: Boolean? = null ): RecommendationConfigEntity { val config = configRepository.findTopByOrderByIdAsc() ?: RecommendationConfigEntity.createDefault().also { configRepository.save(it) } - - dailyCodeCount?.let { + + dailyCodeCount?.let { require(it > 0) { "dailyCodeCount는 0보다 커야 합니다" } - config.dailyCodeCount = it + config.dailyCodeCount = it } - codeTimeCount?.let { + codeTimeCount?.let { require(it > 0) { "codeTimeCount는 0보다 커야 합니다" } - config.codeTimeCount = it + config.codeTimeCount = it } - codeTimeSlots?.let { + codeTimeSlots?.let { require(it.isNotEmpty()) { "codeTimeSlots는 비어있을 수 없습니다" } - config.setCodeTimeSlotsFromList(it) + config.setCodeTimeSlotsFromList(it) } - dailyRefreshTime?.let { + dailyRefreshTime?.let { require(it.matches(Regex("^([01]?[0-9]|2[0-3]):[0-5][0-9]$"))) { "잘못된 시간 형식: $it" } - config.dailyRefreshTime = it + config.dailyRefreshTime = it } - repeatAvoidDays?.let { + repeatAvoidDays?.let { require(it >= 0) { "repeatAvoidDays는 0 이상이어야 합니다" } - config.repeatAvoidDays = it + config.repeatAvoidDays = it } allowDuplicate?.let { config.allowDuplicate = it } - + + // 나이 설정 업데이트 + agePreferredMaxDiff?.let { + require(it >= 0) { "agePreferredMaxDiff는 0 이상이어야 합니다" } + config.agePreferredMaxDiff = it + } + ageCutoffDiff?.let { + require(it > (agePreferredMaxDiff ?: config.agePreferredMaxDiff)) { + "ageCutoffDiff는 agePreferredMaxDiff보다 커야 합니다" + } + config.ageCutoffDiff = it + } + ageAllowCutoffWhenInsufficient?.let { config.ageAllowCutoffWhenInsufficient = it } + val updated = configRepository.save(config) log.info { "추천 시스템 설정 업데이트 완료" } - + return updated } @@ -158,7 +195,10 @@ class RecommendationConfigService( "codeTimeSlots" to config.getCodeTimeSlotsAsList(), "dailyRefreshTime" to config.dailyRefreshTime, "repeatAvoidDays" to config.repeatAvoidDays, - "allowDuplicate" to config.allowDuplicate + "allowDuplicate" to config.allowDuplicate, + "agePreferredMaxDiff" to config.agePreferredMaxDiff, + "ageCutoffDiff" to config.ageCutoffDiff, + "ageAllowCutoffWhenInsufficient" to config.ageAllowCutoffWhenInsufficient ) } } diff --git a/src/main/kotlin/codel/recommendation/domain/AgePreference.kt b/src/main/kotlin/codel/recommendation/domain/AgePreference.kt new file mode 100644 index 00000000..01ca6b8c --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/AgePreference.kt @@ -0,0 +1,59 @@ +package codel.recommendation.domain + +import kotlin.math.abs + +/** + * 나이 선호도 설정 Value Object + * + * 현재는 서비스 전역 설정(Config)에서 가져오지만, + * 미래에는 회원별 개인 설정으로 확장 가능 + * + * @property preferredMaxDiff 우선 추천 최대 나이 차이 (기본: 5) + * @property cutoffDiff 컷오프 기준 나이 차이 (기본: 6, 이 값 이상이면 제외) + * @property allowCutoffWhenInsufficient 후보 부족 시 컷오프 대상 허용 여부 (기본: true) + */ +data class AgePreference( + val preferredMaxDiff: Int = 5, + val cutoffDiff: Int = 6, + val allowCutoffWhenInsufficient: Boolean = true +) { + init { + require(preferredMaxDiff >= 0) { "preferredMaxDiff는 0 이상이어야 합니다: $preferredMaxDiff" } + require(cutoffDiff > preferredMaxDiff) { "cutoffDiff는 preferredMaxDiff보다 커야 합니다: cutoff=$cutoffDiff, preferred=$preferredMaxDiff" } + } + + /** + * 해당 나이 차이가 선호 범위 내인지 확인 + */ + fun isPreferred(ageDiff: Int): Boolean { + return ageDiff <= preferredMaxDiff + } + + /** + * 해당 나이 차이가 컷오프 대상인지 확인 + */ + fun isCutoff(ageDiff: Int): Boolean { + return ageDiff >= cutoffDiff + } + + /** + * 두 나이 사이의 차이가 선호 범위 내인지 확인 + */ + fun isPreferredAge(userAge: Int, targetAge: Int): Boolean { + return isPreferred(abs(userAge - targetAge)) + } + + /** + * 두 나이 사이의 차이가 컷오프 대상인지 확인 + */ + fun isCutoffAge(userAge: Int, targetAge: Int): Boolean { + return isCutoff(abs(userAge - targetAge)) + } + + companion object { + /** + * 기본 설정 (기획서 R_V2 기준) + */ + fun default(): AgePreference = AgePreference() + } +} diff --git a/src/main/kotlin/codel/recommendation/domain/AgeTier.kt b/src/main/kotlin/codel/recommendation/domain/AgeTier.kt new file mode 100644 index 00000000..40f140db --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/AgeTier.kt @@ -0,0 +1,47 @@ +package codel.recommendation.domain + +/** + * 나이 차이에 따른 추천 우선순위 Tier + * + * - A1: 0~2살 차이 (최우선 추천) + * - A2: 3~5살 차이 (우선 추천) + * - A3: 6살 이상 차이 (컷오프 대상, 부족 시에만 허용) + */ +enum class AgeTier( + val minDiff: Int, + val maxDiff: Int, + val priority: Int +) { + A1(0, 2, 1), + A2(3, 5, 2), + A3(6, Int.MAX_VALUE, 3); + + companion object { + /** + * 나이 차이로부터 해당하는 AgeTier를 반환 + */ + fun from(ageDiff: Int): AgeTier { + require(ageDiff >= 0) { "나이 차이는 0 이상이어야 합니다: $ageDiff" } + + return when { + ageDiff <= A1.maxDiff -> A1 + ageDiff <= A2.maxDiff -> A2 + else -> A3 + } + } + + /** + * 선호 범위 내인지 확인 (A1 또는 A2) + */ + fun isPreferred(ageDiff: Int): Boolean { + return from(ageDiff) != A3 + } + + /** + * 컷오프 대상인지 확인 (A3) + */ + fun isCutoff(ageDiff: Int): Boolean { + return from(ageDiff) == A3 + } + } +} diff --git a/src/main/kotlin/codel/recommendation/domain/RecommendationConfig.kt b/src/main/kotlin/codel/recommendation/domain/RecommendationConfig.kt index 136dfc2c..7f9bf034 100644 --- a/src/main/kotlin/codel/recommendation/domain/RecommendationConfig.kt +++ b/src/main/kotlin/codel/recommendation/domain/RecommendationConfig.kt @@ -49,7 +49,27 @@ class RecommendationConfig( */ val allowDuplicate: Boolean get() = configService.getAllowDuplicate() - + + /** + * 우선 추천 최대 나이 차이 (0~N살) + * 이 범위 내의 후보가 우선 추천됨 + */ + val agePreferredMaxDiff: Int + get() = configService.getAgePreferredMaxDiff() + + /** + * 컷오프 기준 나이 차이 + * 이 값 이상의 나이 차이는 기본적으로 추천에서 제외 + */ + val ageCutoffDiff: Int + get() = configService.getAgeCutoffDiff() + + /** + * 후보 부족 시 컷오프 대상 허용 여부 + */ + val ageAllowCutoffWhenInsufficient: Boolean + get() = configService.getAgeAllowCutoffWhenInsufficient() + companion object { /** diff --git a/src/main/kotlin/codel/recommendation/domain/RecommendationConfigEntity.kt b/src/main/kotlin/codel/recommendation/domain/RecommendationConfigEntity.kt index a82c4d06..0f08a896 100644 --- a/src/main/kotlin/codel/recommendation/domain/RecommendationConfigEntity.kt +++ b/src/main/kotlin/codel/recommendation/domain/RecommendationConfigEntity.kt @@ -52,7 +52,29 @@ class RecommendationConfigEntity( */ @Column(nullable = false) var allowDuplicate: Boolean = true, - + + /** + * 우선 추천 최대 나이 차이 (0~N살) + * 이 범위 내의 후보가 우선 추천됨 + */ + @Column(nullable = false) + var agePreferredMaxDiff: Int = 5, + + /** + * 컷오프 기준 나이 차이 + * 이 값 이상의 나이 차이는 기본적으로 추천에서 제외 + */ + @Column(nullable = false) + var ageCutoffDiff: Int = 6, + + /** + * 후보 부족 시 컷오프 대상 허용 여부 + * true: 0~5살 후보가 부족하면 6살 이상도 추천 + * false: 0~5살 후보만 추천 (부족해도 6살 이상 제외) + */ + @Column(nullable = false) + var ageAllowCutoffWhenInsufficient: Boolean = true, + @Column(nullable = false, updatable = false) val createdAt: LocalDateTime = LocalDateTime.now(), @@ -92,7 +114,10 @@ class RecommendationConfigEntity( codeTimeSlots = "10:00,22:00", dailyRefreshTime = "00:00", repeatAvoidDays = 3, - allowDuplicate = true + allowDuplicate = true, + agePreferredMaxDiff = 5, + ageCutoffDiff = 6, + ageAllowCutoffWhenInsufficient = true ) } } diff --git a/src/main/resources/db/migration/V20__add_question_group_and_update_category.sql b/src/main/resources/db/migration/V20__add_question_group_and_update_category.sql new file mode 100644 index 00000000..83c78c97 --- /dev/null +++ b/src/main/resources/db/migration/V20__add_question_group_and_update_category.sql @@ -0,0 +1,29 @@ +-- 채팅방 카테고리 기반 질문 추천 기능을 위한 스키마 변경 +-- Issue: #389 + +-- 1. question_group 컬럼 추가 +ALTER TABLE question +ADD COLUMN question_group ENUM('A', 'B', 'RANDOM') NOT NULL DEFAULT 'RANDOM' +COMMENT '질문 그룹 (A: 가벼운/진입용, B: 깊이/무게감, RANDOM: 그룹없음)'; + +-- 2. category ENUM 확장 (채팅방 전용 카테고리 추가) +-- 기존: VALUES, FAVORITE, CURRENT_ME, DATE, MEMORY, WANT_TALK, BALANCE_ONE, IF +-- 추가: TENSION_UP, SECRET +ALTER TABLE question +MODIFY COLUMN category +ENUM( + 'VALUES', + 'FAVORITE', + 'CURRENT_ME', + 'DATE', + 'MEMORY', + 'WANT_TALK', + 'BALANCE_ONE', + 'IF', + 'TENSION_UP', + 'SECRET' +) NOT NULL; + +-- 3. 인덱스 추가 (질문 추천 성능 향상) +CREATE INDEX idx_question_category_group_active +ON question(category, question_group, is_active); diff --git a/src/main/resources/db/migration/V21__add_age_preference_to_recommendation_config.sql b/src/main/resources/db/migration/V21__add_age_preference_to_recommendation_config.sql new file mode 100644 index 00000000..74f712fa --- /dev/null +++ b/src/main/resources/db/migration/V21__add_age_preference_to_recommendation_config.sql @@ -0,0 +1,16 @@ +-- 추천 시스템 나이 우선순위 설정 필드 추가 + +ALTER TABLE recommendation_config + ADD COLUMN age_preferred_max_diff INT NOT NULL DEFAULT 5 + COMMENT '우선 추천 최대 나이 차이 (0~N살, 기본: 5)' AFTER allow_duplicate, + ADD COLUMN age_cutoff_diff INT NOT NULL DEFAULT 6 + COMMENT '컷오프 기준 나이 차이 (N살 이상 제외, 기본: 6)' AFTER age_preferred_max_diff, + ADD COLUMN age_allow_cutoff_when_insufficient BOOLEAN NOT NULL DEFAULT TRUE + COMMENT '후보 부족 시 컷오프 대상 허용 여부 (기본: true)' AFTER age_cutoff_diff; + +-- 기존 레코드 업데이트 (이미 DEFAULT 값이 설정되므로 필요 없지만 명시적으로) +UPDATE recommendation_config +SET age_preferred_max_diff = 5, + age_cutoff_diff = 6, + age_allow_cutoff_when_insufficient = TRUE +WHERE id = 1; diff --git a/src/main/resources/db/migration/V22__add_values_code_category.sql b/src/main/resources/db/migration/V22__add_values_code_category.sql new file mode 100644 index 00000000..19539fef --- /dev/null +++ b/src/main/resources/db/migration/V22__add_values_code_category.sql @@ -0,0 +1,19 @@ +-- VALUES_CODE 카테고리 추가 (채팅방 전용 가치관 코드) +-- Issue: #397 + +-- category ENUM에 VALUES_CODE 추가 +ALTER TABLE question +MODIFY COLUMN category +ENUM( + 'VALUES', + 'VALUES_CODE', + 'FAVORITE', + 'CURRENT_ME', + 'DATE', + 'MEMORY', + 'WANT_TALK', + 'BALANCE_ONE', + 'IF', + 'TENSION_UP', + 'SECRET' +) NOT NULL; diff --git a/src/main/resources/templates/questionEditForm.html b/src/main/resources/templates/questionEditForm.html index 11afb93b..ed8cf28d 100644 --- a/src/main/resources/templates/questionEditForm.html +++ b/src/main/resources/templates/questionEditForm.html @@ -29,6 +29,28 @@ border-radius: 1rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07); } + + .info-box { + background: #E3F2FD; + border-left: 3px solid #4A90E2; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1.5rem; + } + + .info-box-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: #4A90E2; + } + + .info-box ul { + margin-left: 1.5rem; + font-size: 0.85rem; + color: #7F8C8D; + margin-bottom: 0; + } @@ -49,6 +71,15 @@

질문 수정

+
+
📌 그룹 설정 가이드
+
    +
  • A그룹: 가벼운/진입용 질문 (먼저 노출, 대화 시작에 적합)
  • +
  • B그룹: 깊이있는/무게감 질문 (A그룹 소진 후 노출)
  • +
  • 랜덤: 텐션업 코드 등 그룹 구분 없는 질문
  • +
+
+
@@ -57,6 +88,10 @@

질문 수정

+ + + +
최대 1000자까지 입력 가능합니다.
@@ -112,5 +163,124 @@

질문 수정

+ - \ No newline at end of file + diff --git a/src/main/resources/templates/questionForm.html b/src/main/resources/templates/questionForm.html index aaa53cb6..776292aa 100644 --- a/src/main/resources/templates/questionForm.html +++ b/src/main/resources/templates/questionForm.html @@ -29,6 +29,28 @@ border-radius: 1rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07); } + + .info-box { + background: #E3F2FD; + border-left: 3px solid #4A90E2; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1.5rem; + } + + .info-box-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: #4A90E2; + } + + .info-box ul { + margin-left: 1.5rem; + font-size: 0.85rem; + color: #7F8C8D; + margin-bottom: 0; + } @@ -48,27 +70,53 @@

새 질문 등록

+
+
📌 그룹 설정 가이드
+
    +
  • A그룹: 가벼운/진입용 질문 (먼저 노출, 대화 시작에 적합)
  • +
  • B그룹: 깊이있는/무게감 질문 (A그룹 소진 후 노출)
  • +
  • 랜덤: 텐션업 코드 등 그룹 구분 없는 질문
  • +
+
+
-
최대 500자까지 입력 가능합니다.
+
+ + +
+
+
+ +
+ + +
A그룹은 우선 추천, B그룹은 A그룹 소진 후 추천됩니다. 텐션업 카테고리는 랜덤을 선택하세요.
-
최대 1000자까지 입력 가능합니다.
@@ -99,5 +147,89 @@

새 질문 등록

+ - \ No newline at end of file + diff --git a/src/main/resources/templates/questionList.html b/src/main/resources/templates/questionList.html index 74f617f9..03a87326 100644 --- a/src/main/resources/templates/questionList.html +++ b/src/main/resources/templates/questionList.html @@ -46,32 +46,47 @@

질문 관리

- -
+ +
+ th:value="${searchParams['keyword']}" placeholder="질문 내용 또는 설명 검색">
-
+
+ + +
+
+ +
-
+
+ + +
+
-
-
@@ -87,17 +102,18 @@

질문 관리

ID - 질문 내용 - 카테고리 - 설명 + 질문 내용 + 카테고리 + 그룹 + 설명 상태 등록일 - 관리 + 관리 - + 등록된 질문이 없습니다. @@ -109,6 +125,11 @@

질문 관리

+ + +
@@ -119,17 +140,12 @@

질문 관리

- - - - - -
+ \ No newline at end of file diff --git a/src/test/kotlin/codel/chat/business/strategy/QuestionRecommendStrategyResolverTest.kt b/src/test/kotlin/codel/chat/business/strategy/QuestionRecommendStrategyResolverTest.kt new file mode 100644 index 00000000..1fd5ebb7 --- /dev/null +++ b/src/test/kotlin/codel/chat/business/strategy/QuestionRecommendStrategyResolverTest.kt @@ -0,0 +1,164 @@ +package codel.chat.business.strategy + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock + +class QuestionRecommendStrategyResolverTest { + + private lateinit var categoryBasedStrategy: CategoryBasedQuestionStrategy + private lateinit var legacyRandomStrategy: LegacyRandomQuestionStrategy + private lateinit var resolver: QuestionRecommendStrategyResolver + + @BeforeEach + fun setUp() { + categoryBasedStrategy = mock(CategoryBasedQuestionStrategy::class.java) + legacyRandomStrategy = mock(LegacyRandomQuestionStrategy::class.java) + resolver = QuestionRecommendStrategyResolver(categoryBasedStrategy, legacyRandomStrategy) + } + + @DisplayName("앱 버전이 1.3.0 이상이면 CategoryBasedQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_3_0() { + // given + val appVersion = "1.3.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(categoryBasedStrategy, result) + } + + @DisplayName("앱 버전이 1.5.0이면 CategoryBasedQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_5_0() { + // given + val appVersion = "1.5.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(categoryBasedStrategy, result) + } + + @DisplayName("앱 버전이 2.0.0이면 CategoryBasedQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_2_0_0() { + // given + val appVersion = "2.0.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(categoryBasedStrategy, result) + } + + @DisplayName("앱 버전이 1.2.9이면 LegacyRandomQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_2_9() { + // given + val appVersion = "1.2.9" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(legacyRandomStrategy, result) + } + + @DisplayName("앱 버전이 1.0.0이면 LegacyRandomQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_0_0() { + // given + val appVersion = "1.0.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(legacyRandomStrategy, result) + } + + @DisplayName("앱 버전이 null이면 LegacyRandomQuestionStrategy를 반환한다 (하위호환)") + @Test + fun resolveStrategy_version_null() { + // given + val appVersion: String? = null + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(legacyRandomStrategy, result) + } + + @DisplayName("앱 버전 파싱 실패 시 LegacyRandomQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_invalid_version() { + // given + val appVersion = "invalid-version" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(legacyRandomStrategy, result) + } + + @DisplayName("앱 버전이 빈 문자열이면 LegacyRandomQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_empty_version() { + // given + val appVersion = "" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(legacyRandomStrategy, result) + } + + @DisplayName("앱 버전이 1.3 형식이면 정상 파싱하여 CategoryBasedQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_3() { + // given + val appVersion = "1.3" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(categoryBasedStrategy, result) + } + + @DisplayName("앱 버전이 1.2 형식이면 정상 파싱하여 LegacyRandomQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_2() { + // given + val appVersion = "1.2" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(legacyRandomStrategy, result) + } + + @DisplayName("앱 버전이 10.0.0이면 CategoryBasedQuestionStrategy를 반환한다") + @Test + fun resolveStrategy_version_10_0_0() { + // given + val appVersion = "10.0.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(categoryBasedStrategy, result) + } +} diff --git a/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt b/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt index aedfb207..1578abd8 100644 --- a/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt +++ b/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt @@ -11,39 +11,33 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test -import org.mockito.ArgumentCaptor import org.mockito.Mockito.* import org.springframework.http.HttpStatus import org.springframework.mock.web.MockMultipartFile -import java.time.LocalDate class PreVerificationStrategyTest { private lateinit var signupService: SignupService private lateinit var memberJpaRepository: MemberJpaRepository - private lateinit var asyncNotificationService: IAsyncNotificationService private lateinit var strategy: PreVerificationStrategy + private lateinit var asyncNotificationSet: IAsyncNotificationService @BeforeEach fun setUp() { signupService = mock(SignupService::class.java) memberJpaRepository = mock(MemberJpaRepository::class.java) - asyncNotificationService = mock(IAsyncNotificationService::class.java) - strategy = PreVerificationStrategy(signupService, memberJpaRepository, asyncNotificationService) + asyncNotificationSet = mock(IAsyncNotificationService::class.java) + strategy = PreVerificationStrategy(signupService, memberJpaRepository, asyncNotificationSet) } - @DisplayName("PERSONALITY_COMPLETED 상태에서는 히든 이미지 등록 후 PENDING 상태로 변경한다") + @DisplayName("PERSONALITY_COMPLETED 상태에서는 히든 이미지 등록 후 HIDDEN_COMPLETED 상태로 변경한다") @Test - fun handleHiddenImages_personalityCompleted_changeToPending() { + fun handleHiddenImages_personalityCompleted_changeToHiddenCompleted() { // given val profile = Profile( id = 1L, - codeName = "테스트유저", - bigCity = "서울", - smallCity = "강남구", - birthDate = LocalDate.of(1990, 1, 1) + codeName = "테스트유저" ) - val member = Member( id = 1L, oauthId = "test-oauth-id", @@ -53,15 +47,13 @@ class PreVerificationStrategyTest { profile = profile ) - profile.member = member - val images = listOf( MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray()), MockMultipartFile("image2", "test2.jpg", "image/jpeg", "test2".toByteArray()), MockMultipartFile("image3", "test3.jpg", "image/jpeg", "test3".toByteArray()) ) - // memberJpaRepository.findByMemberId가 member를 반환하도록 mock 설정 + // mock: findByMemberId가 member를 반환하도록 설정 `when`(memberJpaRepository.findByMemberId(1L)).thenReturn(member) // when @@ -69,9 +61,7 @@ class PreVerificationStrategyTest { // then verify(signupService, times(1)).registerHiddenImages(member, images) - verify(memberJpaRepository, times(1)).findByMemberId(1L) - - assertEquals(MemberStatus.PENDING, member.memberStatus) + assertEquals(MemberStatus.PENDING, member.memberStatus) // completeHiddenProfile()이 PENDING으로 변경 assertEquals(HttpStatus.OK, response.statusCode) } -} +} \ No newline at end of file diff --git a/src/test/kotlin/codel/question/business/QuestionServiceRecommendationTest.kt b/src/test/kotlin/codel/question/business/QuestionServiceRecommendationTest.kt new file mode 100644 index 00000000..748d0de3 --- /dev/null +++ b/src/test/kotlin/codel/question/business/QuestionServiceRecommendationTest.kt @@ -0,0 +1,201 @@ +package codel.question.business + +import codel.chat.infrastructure.ChatRoomJpaRepository +import codel.chat.infrastructure.ChatRoomQuestionJpaRepository +import codel.question.domain.GroupPolicy +import codel.question.domain.Question +import codel.question.domain.QuestionCategory +import codel.question.domain.QuestionGroup +import codel.question.infrastructure.QuestionJpaRepository +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.Mockito.* + +class QuestionServiceRecommendationTest { + + private lateinit var questionJpaRepository: QuestionJpaRepository + private lateinit var chatRoomQuestionJpaRepository: ChatRoomQuestionJpaRepository + private lateinit var chatRoomJpaRepository: ChatRoomJpaRepository + private lateinit var questionService: QuestionService + + @BeforeEach + fun setUp() { + questionJpaRepository = mock(QuestionJpaRepository::class.java) + chatRoomQuestionJpaRepository = mock(ChatRoomQuestionJpaRepository::class.java) + chatRoomJpaRepository = mock(ChatRoomJpaRepository::class.java) + questionService = QuestionService( + questionJpaRepository, + chatRoomQuestionJpaRepository, + chatRoomJpaRepository + ) + } + + private fun createQuestion(id: Long, category: QuestionCategory, group: QuestionGroup): Question { + return Question( + id = id, + content = "Test question $id", + category = category, + questionGroup = group, + isActive = true, + description = null + ) + } + + @Nested + @DisplayName("A_THEN_B 그룹 정책 테스트") + inner class AThenBPolicyTest { + + @DisplayName("A그룹 질문이 있으면 A그룹에서 추천한다") + @Test + fun recommend_from_group_a_when_available() { + // given + val chatRoomId = 1L + val category = QuestionCategory.VALUES_CODE + val groupAQuestions = listOf( + createQuestion(1L, category, QuestionGroup.A), + createQuestion(2L, category, QuestionGroup.A) + ) + + `when`(questionJpaRepository.findUnusedQuestionsByChatRoomAndCategoryAndGroup( + chatRoomId, category, QuestionGroup.A + )).thenReturn(groupAQuestions) + + // when + val result = questionService.recommendQuestionForChat(chatRoomId, category) + + // then + assertTrue(result is QuestionRecommendationResult.Success) + val successResult = result as QuestionRecommendationResult.Success + assertEquals(QuestionGroup.A, successResult.question.questionGroup) + + // B그룹 조회가 호출되지 않아야 함 + verify(questionJpaRepository, never()).findUnusedQuestionsByChatRoomAndCategoryAndGroup( + chatRoomId, category, QuestionGroup.B + ) + } + + @DisplayName("A그룹이 소진되면 B그룹에서 추천한다") + @Test + fun recommend_from_group_b_when_group_a_exhausted() { + // given + val chatRoomId = 1L + val category = QuestionCategory.VALUES_CODE + val groupBQuestions = listOf( + createQuestion(3L, category, QuestionGroup.B), + createQuestion(4L, category, QuestionGroup.B) + ) + + `when`(questionJpaRepository.findUnusedQuestionsByChatRoomAndCategoryAndGroup( + chatRoomId, category, QuestionGroup.A + )).thenReturn(emptyList()) + + `when`(questionJpaRepository.findUnusedQuestionsByChatRoomAndCategoryAndGroup( + chatRoomId, category, QuestionGroup.B + )).thenReturn(groupBQuestions) + + // when + val result = questionService.recommendQuestionForChat(chatRoomId, category) + + // then + assertTrue(result is QuestionRecommendationResult.Success) + val successResult = result as QuestionRecommendationResult.Success + assertEquals(QuestionGroup.B, successResult.question.questionGroup) + } + + @DisplayName("A그룹과 B그룹 모두 소진되면 Exhausted를 반환한다") + @Test + fun return_exhausted_when_all_groups_exhausted() { + // given + val chatRoomId = 1L + val category = QuestionCategory.VALUES_CODE + + `when`(questionJpaRepository.findUnusedQuestionsByChatRoomAndCategoryAndGroup( + chatRoomId, category, QuestionGroup.A + )).thenReturn(emptyList()) + + `when`(questionJpaRepository.findUnusedQuestionsByChatRoomAndCategoryAndGroup( + chatRoomId, category, QuestionGroup.B + )).thenReturn(emptyList()) + + // when + val result = questionService.recommendQuestionForChat(chatRoomId, category) + + // then + assertTrue(result is QuestionRecommendationResult.Exhausted) + } + } + + @Nested + @DisplayName("RANDOM 그룹 정책 테스트") + inner class RandomPolicyTest { + + @DisplayName("TENSION_UP은 그룹 구분 없이 랜덤 추천한다") + @Test + fun tension_up_recommend_randomly() { + // given + val chatRoomId = 1L + val category = QuestionCategory.TENSION_UP + val allQuestions = listOf( + createQuestion(1L, category, QuestionGroup.RANDOM), + createQuestion(2L, category, QuestionGroup.RANDOM) + ) + + `when`(questionJpaRepository.findUnusedQuestionsByChatRoomAndCategory( + chatRoomId, category + )).thenReturn(allQuestions) + + // when + val result = questionService.recommendQuestionForChat(chatRoomId, category) + + // then + assertTrue(result is QuestionRecommendationResult.Success) + + // findUnusedQuestionsByChatRoomAndCategory가 호출되었는지 확인 + verify(questionJpaRepository).findUnusedQuestionsByChatRoomAndCategory(chatRoomId, category) + } + + @DisplayName("TENSION_UP 질문이 소진되면 Exhausted를 반환한다") + @Test + fun tension_up_return_exhausted_when_empty() { + // given + val chatRoomId = 1L + val category = QuestionCategory.TENSION_UP + + `when`(questionJpaRepository.findUnusedQuestionsByChatRoomAndCategory( + chatRoomId, category + )).thenReturn(emptyList()) + + // when + val result = questionService.recommendQuestionForChat(chatRoomId, category) + + // then + assertTrue(result is QuestionRecommendationResult.Exhausted) + } + } + + @Nested + @DisplayName("카테고리별 그룹 정책 확인") + inner class CategoryGroupPolicyTest { + + @DisplayName("IF 카테고리는 A_THEN_B 정책을 사용한다") + @Test + fun if_category_uses_a_then_b() { + assertEquals(GroupPolicy.A_THEN_B, QuestionCategory.IF.chatGroupPolicy) + } + + @DisplayName("SECRET 카테고리는 A_THEN_B 정책을 사용한다") + @Test + fun secret_category_uses_a_then_b() { + assertEquals(GroupPolicy.A_THEN_B, QuestionCategory.SECRET.chatGroupPolicy) + } + + @DisplayName("TENSION_UP 카테고리는 RANDOM 정책을 사용한다") + @Test + fun tension_up_category_uses_random() { + assertEquals(GroupPolicy.RANDOM, QuestionCategory.TENSION_UP.chatGroupPolicy) + } + } +} diff --git a/src/test/kotlin/codel/question/domain/QuestionCategoryTest.kt b/src/test/kotlin/codel/question/domain/QuestionCategoryTest.kt new file mode 100644 index 00000000..1994d768 --- /dev/null +++ b/src/test/kotlin/codel/question/domain/QuestionCategoryTest.kt @@ -0,0 +1,213 @@ +package codel.question.domain + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class QuestionCategoryTest { + + @Nested + @DisplayName("카테고리 용도 테스트") + inner class CategoryUsageTest { + + @DisplayName("VALUES는 회원가입에서만 사용된다") + @Test + fun values_used_in_signup_only() { + // given + val category = QuestionCategory.VALUES + + // then + assertTrue(category.usedInSignup) + assertFalse(category.usedInChat) + assertTrue(category.isSignupCategory()) + assertFalse(category.isChatCategory()) + assertEquals(GroupPolicy.NONE, category.chatGroupPolicy) + } + + @DisplayName("VALUES_CODE는 채팅방에서만 사용된다") + @Test + fun values_code_used_in_chat_only() { + // given + val category = QuestionCategory.VALUES_CODE + + // then + assertFalse(category.usedInSignup) + assertTrue(category.usedInChat) + assertFalse(category.isSignupCategory()) + assertTrue(category.isChatCategory()) + assertEquals(GroupPolicy.A_THEN_B, category.chatGroupPolicy) + } + + @DisplayName("FAVORITE은 회원가입에서만 사용된다") + @Test + fun favorite_used_in_signup_only() { + // given + val category = QuestionCategory.FAVORITE + + // then + assertTrue(category.usedInSignup) + assertFalse(category.usedInChat) + assertTrue(category.isSignupCategory()) + assertFalse(category.isChatCategory()) + } + + @DisplayName("TENSION_UP은 채팅방에서만 사용된다") + @Test + fun tension_up_used_in_chat_only() { + // given + val category = QuestionCategory.TENSION_UP + + // then + assertFalse(category.usedInSignup) + assertTrue(category.usedInChat) + assertFalse(category.isSignupCategory()) + assertTrue(category.isChatCategory()) + } + + @DisplayName("IF는 채팅방에서만 사용된다") + @Test + fun if_used_in_chat_only() { + // given + val category = QuestionCategory.IF + + // then + assertFalse(category.usedInSignup) + assertTrue(category.usedInChat) + } + + @DisplayName("SECRET은 채팅방에서만 사용된다") + @Test + fun secret_used_in_chat_only() { + // given + val category = QuestionCategory.SECRET + + // then + assertFalse(category.usedInSignup) + assertTrue(category.usedInChat) + } + } + + @Nested + @DisplayName("그룹 정책 테스트") + inner class GroupPolicyTest { + + @DisplayName("VALUES_CODE는 A_THEN_B 그룹 정책을 사용한다") + @Test + fun values_code_has_a_then_b_policy() { + // given + val category = QuestionCategory.VALUES_CODE + + // then + assertEquals(GroupPolicy.A_THEN_B, category.chatGroupPolicy) + } + + @DisplayName("TENSION_UP은 RANDOM 그룹 정책을 사용한다") + @Test + fun tension_up_has_random_policy() { + // given + val category = QuestionCategory.TENSION_UP + + // then + assertEquals(GroupPolicy.RANDOM, category.chatGroupPolicy) + } + + @DisplayName("IF는 A_THEN_B 그룹 정책을 사용한다") + @Test + fun if_has_a_then_b_policy() { + // given + val category = QuestionCategory.IF + + // then + assertEquals(GroupPolicy.A_THEN_B, category.chatGroupPolicy) + } + + @DisplayName("SECRET은 A_THEN_B 그룹 정책을 사용한다") + @Test + fun secret_has_a_then_b_policy() { + // given + val category = QuestionCategory.SECRET + + // then + assertEquals(GroupPolicy.A_THEN_B, category.chatGroupPolicy) + } + + @DisplayName("회원가입 전용 카테고리는 NONE 그룹 정책을 사용한다") + @Test + fun signup_only_categories_have_none_policy() { + // given + val signupOnlyCategories = listOf( + QuestionCategory.VALUES, + QuestionCategory.FAVORITE, + QuestionCategory.CURRENT_ME, + QuestionCategory.DATE, + QuestionCategory.MEMORY, + QuestionCategory.WANT_TALK + ) + + // then + signupOnlyCategories.forEach { category -> + assertEquals(GroupPolicy.NONE, category.chatGroupPolicy, + "${category.name}는 NONE 정책이어야 합니다") + } + } + } + + @Nested + @DisplayName("카테고리 조회 테스트") + inner class CategoryQueryTest { + + @DisplayName("회원가입 카테고리 목록을 조회한다") + @Test + fun getSignupCategories() { + // when + val signupCategories = QuestionCategory.getSignupCategories() + + // then + assertTrue(signupCategories.contains(QuestionCategory.VALUES)) + assertTrue(signupCategories.contains(QuestionCategory.FAVORITE)) + assertTrue(signupCategories.contains(QuestionCategory.CURRENT_ME)) + assertTrue(signupCategories.contains(QuestionCategory.DATE)) + assertTrue(signupCategories.contains(QuestionCategory.MEMORY)) + assertTrue(signupCategories.contains(QuestionCategory.WANT_TALK)) + assertFalse(signupCategories.contains(QuestionCategory.VALUES_CODE)) + assertFalse(signupCategories.contains(QuestionCategory.TENSION_UP)) + assertFalse(signupCategories.contains(QuestionCategory.IF)) + assertFalse(signupCategories.contains(QuestionCategory.SECRET)) + } + + @DisplayName("채팅방 카테고리 목록을 조회한다") + @Test + fun getChatCategories() { + // when + val chatCategories = QuestionCategory.getChatCategories() + + // then + assertTrue(chatCategories.contains(QuestionCategory.VALUES_CODE)) + assertTrue(chatCategories.contains(QuestionCategory.TENSION_UP)) + assertTrue(chatCategories.contains(QuestionCategory.IF)) + assertTrue(chatCategories.contains(QuestionCategory.SECRET)) + assertFalse(chatCategories.contains(QuestionCategory.VALUES)) + assertFalse(chatCategories.contains(QuestionCategory.FAVORITE)) + assertFalse(chatCategories.contains(QuestionCategory.DATE)) + } + + @DisplayName("문자열로 카테고리를 조회한다") + @Test + fun fromString_valid() { + // when & then + assertEquals(QuestionCategory.VALUES, QuestionCategory.fromString("VALUES")) + assertEquals(QuestionCategory.VALUES, QuestionCategory.fromString("values")) + assertEquals(QuestionCategory.TENSION_UP, QuestionCategory.fromString("TENSION_UP")) + } + + @DisplayName("잘못된 문자열로 조회 시 null을 반환한다") + @Test + fun fromString_invalid() { + // when & then + assertNull(QuestionCategory.fromString("INVALID")) + assertNull(QuestionCategory.fromString(null)) + assertNull(QuestionCategory.fromString("")) + } + } +} diff --git a/src/test/kotlin/codel/question/domain/QuestionGroupTest.kt b/src/test/kotlin/codel/question/domain/QuestionGroupTest.kt new file mode 100644 index 00000000..739313f1 --- /dev/null +++ b/src/test/kotlin/codel/question/domain/QuestionGroupTest.kt @@ -0,0 +1,24 @@ +package codel.question.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class QuestionGroupTest { + + @DisplayName("QuestionGroup enum 값들이 올바른 displayName을 가진다") + @Test + fun questionGroup_displayName() { + // then + assertEquals("A그룹", QuestionGroup.A.displayName) + assertEquals("B그룹", QuestionGroup.B.displayName) + assertEquals("랜덤", QuestionGroup.RANDOM.displayName) + } + + @DisplayName("QuestionGroup enum에 3개의 값이 존재한다") + @Test + fun questionGroup_values_count() { + // then + assertEquals(3, QuestionGroup.entries.size) + } +} diff --git a/src/test/kotlin/codel/recommendation/business/AgePreferenceResolverTest.kt b/src/test/kotlin/codel/recommendation/business/AgePreferenceResolverTest.kt new file mode 100644 index 00000000..10f4009d --- /dev/null +++ b/src/test/kotlin/codel/recommendation/business/AgePreferenceResolverTest.kt @@ -0,0 +1,88 @@ +package codel.recommendation.business + +import codel.member.domain.Member +import codel.recommendation.domain.AgePreference +import codel.recommendation.domain.RecommendationConfig +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +@DisplayName("AgePreferenceResolver 테스트") +class AgePreferenceResolverTest { + + private lateinit var recommendationConfig: RecommendationConfig + private lateinit var resolver: AgePreferenceResolver + + @BeforeEach + fun setUp() { + recommendationConfig = mock(RecommendationConfig::class.java) + resolver = AgePreferenceResolver(recommendationConfig) + } + + @Nested + @DisplayName("resolve 메서드") + inner class ResolveTest { + + @Test + @DisplayName("Config 기본값을 반환한다") + fun returnsConfigValues() { + // given + val member = mock(Member::class.java) + `when`(recommendationConfig.agePreferredMaxDiff).thenReturn(5) + `when`(recommendationConfig.ageCutoffDiff).thenReturn(6) + `when`(recommendationConfig.ageAllowCutoffWhenInsufficient).thenReturn(true) + + // when + val result = resolver.resolve(member) + + // then + assertEquals(5, result.preferredMaxDiff) + assertEquals(6, result.cutoffDiff) + assertTrue(result.allowCutoffWhenInsufficient) + } + + @Test + @DisplayName("커스텀 Config 값을 반환한다") + fun returnsCustomConfigValues() { + // given + val member = mock(Member::class.java) + `when`(recommendationConfig.agePreferredMaxDiff).thenReturn(3) + `when`(recommendationConfig.ageCutoffDiff).thenReturn(10) + `when`(recommendationConfig.ageAllowCutoffWhenInsufficient).thenReturn(false) + + // when + val result = resolver.resolve(member) + + // then + assertEquals(3, result.preferredMaxDiff) + assertEquals(10, result.cutoffDiff) + assertFalse(result.allowCutoffWhenInsufficient) + } + } + + @Nested + @DisplayName("resolveDefault 메서드") + inner class ResolveDefaultTest { + + @Test + @DisplayName("회원 정보 없이 Config 값을 반환한다") + fun returnsConfigValuesWithoutMember() { + // given + `when`(recommendationConfig.agePreferredMaxDiff).thenReturn(5) + `when`(recommendationConfig.ageCutoffDiff).thenReturn(6) + `when`(recommendationConfig.ageAllowCutoffWhenInsufficient).thenReturn(true) + + // when + val result = resolver.resolveDefault() + + // then + assertEquals(5, result.preferredMaxDiff) + assertEquals(6, result.cutoffDiff) + assertTrue(result.allowCutoffWhenInsufficient) + } + } +} diff --git a/src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt b/src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt index 643ac524..a541fb5a 100644 --- a/src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt +++ b/src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt @@ -5,22 +5,17 @@ import codel.member.domain.MemberStatus import codel.member.domain.OauthType import codel.member.domain.Profile import codel.recommendation.domain.RecommendationConfig -import codel.recommendation.domain.RecommendationType import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test -import org.mockito.kotlin.* +import org.mockito.Mockito +import org.mockito.Mockito.mock import java.time.LocalDate import java.time.LocalDateTime /** * CodeTimeService 테스트 - * - * 주요 검증 사항: - * 1. 추천 세션 일관성 - 시그널 보내도 같은 세션 내에서 계속 표시 - * 2. 차단 관계만 즉시 제외 - * 3. 추천 순서 유지 */ @DisplayName("CodeTimeService 테스트") class CodeTimeServiceTest { @@ -31,71 +26,51 @@ class CodeTimeServiceTest { private lateinit var historyService: RecommendationHistoryService private lateinit var exclusionService: RecommendationExclusionService private lateinit var timeZoneService: TimeZoneService + private lateinit var agePreferenceResolver: AgePreferenceResolver + + private val testTimeSlot = "10:00" + private val testStartTime: LocalDateTime = LocalDateTime.of(2025, 1, 28, 1, 0) + private val testEndTime: LocalDateTime = LocalDateTime.of(2025, 1, 28, 13, 0) @BeforeEach fun setUp() { - config = mock() - bucketService = mock() - historyService = mock() - exclusionService = mock() - timeZoneService = mock() + config = mock(RecommendationConfig::class.java) + bucketService = mock(RecommendationBucketService::class.java) + historyService = mock(RecommendationHistoryService::class.java) + exclusionService = mock(RecommendationExclusionService::class.java) + timeZoneService = mock(TimeZoneService::class.java) + agePreferenceResolver = mock(AgePreferenceResolver::class.java) codeTimeService = CodeTimeService( config = config, bucketService = bucketService, historyService = historyService, exclusionService = exclusionService, - timeZoneService = timeZoneService + timeZoneService = timeZoneService, + agePreferenceResolver = agePreferenceResolver ) // 기본 설정 - whenever(config.codeTimeCount).thenReturn(2) - whenever(config.codeTimeSlots).thenReturn(listOf("10:00", "22:00")) + Mockito.`when`(config.codeTimeCount).thenReturn(2) + Mockito.`when`(config.codeTimeSlots).thenReturn(listOf("10:00", "22:00")) } - @Test - @DisplayName("기존 추천이 없으면 새로운 추천을 생성한다") - fun createNewRecommendation_WhenNoHistory() { - // given - val user = createTestMember(1L, "사용자A") - val recommendedMembers = listOf( - createTestMember(2L, "추천B"), - createTestMember(3L, "추천C") - ) - - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) - ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(emptyList()) - - // 새로운 추천 생성 관련 Mock - whenever(exclusionService.getAllExcludedIds(user, RecommendationType.CODE_TIME)).thenReturn(setOf(1L)) - whenever(bucketService.getCandidatesByBucket(any(), any(), any(), any())).thenReturn(recommendedMembers) - doNothing().whenever(historyService).saveRecommendationHistory(any(), any(), any(), any(), any()) - - // when - val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) - - // then - assertEquals(2, result.content.size) - assertEquals(recommendedMembers, result.content) - - // 추천 이력 저장 확인 - verify(historyService, times(1)) - .saveRecommendationHistory( - eq(user), - eq(recommendedMembers), - eq(RecommendationType.CODE_TIME), - eq("10:00"), - any() - ) + private fun setupTimeZoneForTest() { + Mockito.lenient().`when`(timeZoneService.getCurrentTimeSlot(anyNullable())) + .thenReturn(testTimeSlot) + Mockito.lenient().`when`(timeZoneService.getTimeSlotRangeInUTC(Mockito.anyString(), anyNullable())) + .thenReturn(Pair(testStartTime, testEndTime)) } + @Suppress("UNCHECKED_CAST") + private fun anyNullable(): T = Mockito.any() as T + @Test - @DisplayName("기존 추천이 있으면 실시간 필터링 후 반환한다") - fun returnExistingRecommendation_WithRealTimeFiltering() { + @DisplayName("기존 추천이 있으면 반환한다") + fun returnExistingRecommendation() { // given + setupTimeZoneForTest() + val user = createTestMember(1L, "사용자A") val existingIds = listOf(2L, 3L, 4L) val existingMembers = listOf( @@ -104,15 +79,13 @@ class CodeTimeServiceTest { createTestMember(4L, "추천D") ) - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) - ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + Mockito.`when`(historyService.getCodeTimeIdsByTimeRange( + anyNullable(), Mockito.anyString(), anyNullable(), anyNullable() + )).thenReturn(existingIds) - // 실시간 필터링 - 차단 없음 - whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) - whenever(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) + Mockito.`when`(exclusionService.getBlockedMemberIds(anyNullable())).thenReturn(emptySet()) + Mockito.`when`(exclusionService.getRecentSignalMemberIds(anyNullable())).thenReturn(emptySet()) + Mockito.`when`(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) // when val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) @@ -120,16 +93,14 @@ class CodeTimeServiceTest { // then assertEquals(3, result.content.size) assertEquals(existingMembers, result.content) - - // 새로운 추천 생성하지 않았는지 확인 - verify(historyService, never()) - .saveRecommendationHistory(any(), any(), any(), any(), any()) } @Test - @DisplayName("차단한 사용자는 실시간 필터링에서 즉시 제외된다") - fun filterBlockedMembers_InRealTime() { + @DisplayName("차단한 사용자는 필터링된다") + fun filterBlockedMembers() { // given + setupTimeZoneForTest() + val user = createTestMember(1L, "사용자A") val existingIds = listOf(2L, 3L, 4L) val memberB = createTestMember(2L, "추천B") @@ -137,104 +108,35 @@ class CodeTimeServiceTest { val memberD = createTestMember(4L, "추천D") val allMembers = listOf(memberB, memberC, memberD) - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) - ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + Mockito.`when`(historyService.getCodeTimeIdsByTimeRange( + anyNullable(), Mockito.anyString(), anyNullable(), anyNullable() + )).thenReturn(existingIds) - // 실시간 필터링 - B를 차단함 - whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(setOf(2L)) - // getMembersByIds는 요청된 ID에 해당하는 멤버만 반환 - whenever(bucketService.getMembersByIds(any())).thenAnswer { invocation -> - val requestedIds = invocation.getArgument>(0) - allMembers.filter { it.id in requestedIds } - } + Mockito.`when`(exclusionService.getBlockedMemberIds(anyNullable())).thenReturn(setOf(2L)) + Mockito.`when`(exclusionService.getRecentSignalMemberIds(anyNullable())).thenReturn(emptySet()) + // 첫 번째 호출: 모든 멤버 반환 (WITHDRAWN 필터링용) + Mockito.`when`(bucketService.getMembersByIds(existingIds)).thenReturn(allMembers) + // 두 번째 호출: 필터링된 멤버만 반환 + val filteredMembers = listOf(memberC, memberD) + Mockito.`when`(bucketService.getMembersByIds(listOf(3L, 4L))).thenReturn(filteredMembers) // when val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) // then assertEquals(2, result.content.size) - assertTrue(result.content.none { it.id == 2L }) // B는 제외됨 - assertTrue(result.content.any { it.id == 3L }) // C는 포함됨 - assertTrue(result.content.any { it.id == 4L }) // D는 포함됨 - } - - @Test - @DisplayName("시그널 보낸 사용자는 실시간 필터링에서 제외되지 않는다 - 추천 세션 일관성 유지") - fun doNotFilterSignaledMembers_InRealTime() { - // given - val user = createTestMember(1L, "사용자A") - val existingIds = listOf(2L, 3L, 4L) - val existingMembers = listOf( - createTestMember(2L, "추천B-시그널보냄"), - createTestMember(3L, "추천C"), - createTestMember(4L, "추천D") - ) - - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) - ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) - - // 실시간 필터링 - 차단 없음 (시그널 관계는 체크하지 않음) - whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) - // ⚠️ getRecentSignalMemberIds는 호출되지 않아야 함 - whenever(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) - - // when - val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) - - // then - assertEquals(3, result.content.size) - assertTrue(result.content.any { it.id == 2L }) // B는 시그널 보냈지만 여전히 표시됨 ✅ - - // getRecentSignalMemberIds가 호출되지 않았는지 확인 - verify(exclusionService, never()).getRecentSignalMemberIds(any()) - } - - @Test - @DisplayName("WITHDRAWN 상태의 회원은 자동으로 필터링된다") - fun filterWithdrawnMembers_Automatically() { - // given - val user = createTestMember(1L, "사용자A") - val existingIds = listOf(2L, 3L, 4L) - val memberC = createTestMember(3L, "추천C", MemberStatus.DONE) - val memberD = createTestMember(4L, "추천D", MemberStatus.DONE) - // memberB(2L)는 WITHDRAWN이므로 getMembersByIds에서 자동으로 필터링됨 - val activeMembers = listOf(memberC, memberD) - - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) - ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) - - whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) - // getMembersByIds는 WITHDRAWN을 자동으로 필터링하고 요청된 ID에 해당하는 멤버만 반환 - whenever(bucketService.getMembersByIds(any())).thenAnswer { invocation -> - val requestedIds = invocation.getArgument>(0) - activeMembers.filter { it.id in requestedIds } - } - - // when - val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) - - // then - assertEquals(2, result.content.size) - assertTrue(result.content.none { it.id == 2L }) // B(탈퇴)는 제외됨 + assertFalse(result.content.any { it.id == 2L }) assertTrue(result.content.any { it.id == 3L }) assertTrue(result.content.any { it.id == 4L }) } @Test - @DisplayName("추천 순서가 유지된다 - getMembersByIds의 순서 보존") + @DisplayName("추천 순서가 유지된다") fun maintainRecommendationOrder() { // given + setupTimeZoneForTest() + val user = createTestMember(1L, "사용자A") - // 순서: B1 버킷, B1 버킷, B2 버킷 val existingIds = listOf(2L, 3L, 4L) val orderedMembers = listOf( createTestMember(2L, "B1-강남"), @@ -242,42 +144,44 @@ class CodeTimeServiceTest { createTestMember(4L, "B2-홍대") ) - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) - ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + Mockito.`when`(historyService.getCodeTimeIdsByTimeRange( + anyNullable(), Mockito.anyString(), anyNullable(), anyNullable() + )).thenReturn(existingIds) - whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) - // getMembersByIds는 입력 순서를 보존함 - whenever(bucketService.getMembersByIds(existingIds)).thenReturn(orderedMembers) + Mockito.`when`(exclusionService.getBlockedMemberIds(anyNullable())).thenReturn(emptySet()) + Mockito.`when`(exclusionService.getRecentSignalMemberIds(anyNullable())).thenReturn(emptySet()) + Mockito.`when`(bucketService.getMembersByIds(existingIds)).thenReturn(orderedMembers) // when val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) // then assertEquals(3, result.content.size) - assertEquals(2L, result.content[0].id) // 첫 번째: B1-강남 - assertEquals(3L, result.content[1].id) // 두 번째: B1-강남2 - assertEquals(4L, result.content[2].id) // 세 번째: B2-홍대 + assertEquals(2L, result.content[0].id) + assertEquals(3L, result.content[1].id) + assertEquals(4L, result.content[2].id) } @Test @DisplayName("모든 추천이 필터링되면 빈 페이지를 반환한다") fun returnEmptyPage_WhenAllFiltered() { // given + setupTimeZoneForTest() + val user = createTestMember(1L, "사용자A") val existingIds = listOf(2L, 3L) - - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + val existingMembers = listOf( + createTestMember(2L, "추천B"), + createTestMember(3L, "추천C") ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) - // 모두 차단 - whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(setOf(2L, 3L)) - whenever(bucketService.getMembersByIds(existingIds)).thenReturn(emptyList()) + Mockito.`when`(historyService.getCodeTimeIdsByTimeRange( + anyNullable(), Mockito.anyString(), anyNullable(), anyNullable() + )).thenReturn(existingIds) + + Mockito.`when`(exclusionService.getBlockedMemberIds(anyNullable())).thenReturn(setOf(2L, 3L)) + Mockito.`when`(exclusionService.getRecentSignalMemberIds(anyNullable())).thenReturn(emptySet()) + Mockito.`when`(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) // when val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) @@ -287,35 +191,6 @@ class CodeTimeServiceTest { assertEquals(0, result.totalElements) } - @Test - @DisplayName("페이징이 올바르게 적용된다") - fun applyPaginationCorrectly() { - // given - val user = createTestMember(1L, "사용자A") - val existingIds = listOf(2L, 3L, 4L, 5L, 6L) - val existingMembers = (2L..6L).map { createTestMember(it, "추천$it") } - - whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") - whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( - Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) - ) - whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) - - whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) - whenever(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) - - // when - val page = 0 - val size = 3 - val result = codeTimeService.getCodeTimeRecommendation(user, page, size) - - // then - assertEquals(5, result.content.size) // 페이징은 PageImpl에서 처리되므로 전체 반환 - assertEquals(5, result.totalElements) - } - - // Helper methods - private fun createTestMember( id: Long, name: String, @@ -342,4 +217,4 @@ class CodeTimeServiceTest { return member } -} +} \ No newline at end of file diff --git a/src/test/kotlin/codel/recommendation/domain/AgePreferenceTest.kt b/src/test/kotlin/codel/recommendation/domain/AgePreferenceTest.kt new file mode 100644 index 00000000..78677781 --- /dev/null +++ b/src/test/kotlin/codel/recommendation/domain/AgePreferenceTest.kt @@ -0,0 +1,167 @@ +package codel.recommendation.domain + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +@DisplayName("AgePreference 테스트") +class AgePreferenceTest { + + @Nested + @DisplayName("생성자 검증") + inner class ConstructorValidationTest { + + @Test + @DisplayName("기본값으로 생성 가능") + fun createWithDefaults() { + // when + val pref = AgePreference() + + // then + assertEquals(5, pref.preferredMaxDiff) + assertEquals(6, pref.cutoffDiff) + assertTrue(pref.allowCutoffWhenInsufficient) + } + + @Test + @DisplayName("커스텀 값으로 생성 가능") + fun createWithCustomValues() { + // when + val pref = AgePreference( + preferredMaxDiff = 3, + cutoffDiff = 10, + allowCutoffWhenInsufficient = false + ) + + // then + assertEquals(3, pref.preferredMaxDiff) + assertEquals(10, pref.cutoffDiff) + assertFalse(pref.allowCutoffWhenInsufficient) + } + + @Test + @DisplayName("preferredMaxDiff가 음수면 예외 발생") + fun negativePreferredMaxDiffThrowsException() { + assertThrows(IllegalArgumentException::class.java) { + AgePreference(preferredMaxDiff = -1, cutoffDiff = 6) + } + } + + @Test + @DisplayName("cutoffDiff가 preferredMaxDiff 이하면 예외 발생") + fun cutoffLessThanPreferredThrowsException() { + assertThrows(IllegalArgumentException::class.java) { + AgePreference(preferredMaxDiff = 5, cutoffDiff = 5) + } + + assertThrows(IllegalArgumentException::class.java) { + AgePreference(preferredMaxDiff = 5, cutoffDiff = 3) + } + } + } + + @Nested + @DisplayName("isPreferred 메서드") + inner class IsPreferredTest { + + private val pref = AgePreference(preferredMaxDiff = 5, cutoffDiff = 6) + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3, 4, 5]) + @DisplayName("preferredMaxDiff 이하면 true") + fun withinPreferredRangeReturnsTrue(ageDiff: Int) { + assertTrue(pref.isPreferred(ageDiff)) + } + + @ParameterizedTest + @ValueSource(ints = [6, 7, 10, 20]) + @DisplayName("preferredMaxDiff 초과면 false") + fun exceedsPreferredRangeReturnsFalse(ageDiff: Int) { + assertFalse(pref.isPreferred(ageDiff)) + } + } + + @Nested + @DisplayName("isCutoff 메서드") + inner class IsCutoffTest { + + private val pref = AgePreference(preferredMaxDiff = 5, cutoffDiff = 6) + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3, 4, 5]) + @DisplayName("cutoffDiff 미만이면 false") + fun belowCutoffReturnsFalse(ageDiff: Int) { + assertFalse(pref.isCutoff(ageDiff)) + } + + @ParameterizedTest + @ValueSource(ints = [6, 7, 10, 20]) + @DisplayName("cutoffDiff 이상이면 true") + fun atOrAboveCutoffReturnsTrue(ageDiff: Int) { + assertTrue(pref.isCutoff(ageDiff)) + } + } + + @Nested + @DisplayName("isPreferredAge 메서드") + inner class IsPreferredAgeTest { + + private val pref = AgePreference(preferredMaxDiff = 5, cutoffDiff = 6) + + @ParameterizedTest + @CsvSource( + "26, 26, true", // 0살 차이 + "26, 28, true", // 2살 차이 + "26, 31, true", // 5살 차이 + "26, 21, true", // 5살 차이 (반대) + "26, 32, false", // 6살 차이 + "26, 33, false", // 7살 차이 + "26, 20, false" // 6살 차이 (반대) + ) + @DisplayName("두 나이 간 선호 범위 확인") + fun checkPreferredAge(userAge: Int, targetAge: Int, expected: Boolean) { + assertEquals(expected, pref.isPreferredAge(userAge, targetAge)) + } + } + + @Nested + @DisplayName("isCutoffAge 메서드") + inner class IsCutoffAgeTest { + + private val pref = AgePreference(preferredMaxDiff = 5, cutoffDiff = 6) + + @ParameterizedTest + @CsvSource( + "26, 26, false", // 0살 차이 + "26, 31, false", // 5살 차이 + "26, 32, true", // 6살 차이 + "26, 33, true", // 7살 차이 + "26, 20, true" // 6살 차이 (반대) + ) + @DisplayName("두 나이 간 컷오프 대상 확인") + fun checkCutoffAge(userAge: Int, targetAge: Int, expected: Boolean) { + assertEquals(expected, pref.isCutoffAge(userAge, targetAge)) + } + } + + @Nested + @DisplayName("default 팩토리 메서드") + inner class DefaultFactoryTest { + + @Test + @DisplayName("기본값과 동일한 설정 반환") + fun defaultReturnsDefaultValues() { + // when + val pref = AgePreference.default() + + // then + assertEquals(5, pref.preferredMaxDiff) + assertEquals(6, pref.cutoffDiff) + assertTrue(pref.allowCutoffWhenInsufficient) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/codel/recommendation/domain/AgeTierTest.kt b/src/test/kotlin/codel/recommendation/domain/AgeTierTest.kt new file mode 100644 index 00000000..c9ac4f51 --- /dev/null +++ b/src/test/kotlin/codel/recommendation/domain/AgeTierTest.kt @@ -0,0 +1,122 @@ +package codel.recommendation.domain + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +@DisplayName("AgeTier 테스트") +class AgeTierTest { + + @Nested + @DisplayName("from 메서드") + inner class FromTest { + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2]) + @DisplayName("나이 차이 0~2살이면 A1 반환") + fun ageDiff0to2ReturnsA1(ageDiff: Int) { + // when + val tier = AgeTier.from(ageDiff) + + // then + assertEquals(AgeTier.A1, tier) + } + + @ParameterizedTest + @ValueSource(ints = [3, 4, 5]) + @DisplayName("나이 차이 3~5살이면 A2 반환") + fun ageDiff3to5ReturnsA2(ageDiff: Int) { + // when + val tier = AgeTier.from(ageDiff) + + // then + assertEquals(AgeTier.A2, tier) + } + + @ParameterizedTest + @ValueSource(ints = [6, 7, 10, 20, 100]) + @DisplayName("나이 차이 6살 이상이면 A3 반환") + fun ageDiff6PlusReturnsA3(ageDiff: Int) { + // when + val tier = AgeTier.from(ageDiff) + + // then + assertEquals(AgeTier.A3, tier) + } + + @Test + @DisplayName("음수 나이 차이는 예외 발생") + fun negativeAgeDiffThrowsException() { + // when & then + assertThrows(IllegalArgumentException::class.java) { + AgeTier.from(-1) + } + } + } + + @Nested + @DisplayName("isPreferred 메서드") + inner class IsPreferredTest { + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3, 4, 5]) + @DisplayName("0~5살 차이면 선호 범위(true)") + fun preferredRangeReturnsTrue(ageDiff: Int) { + assertTrue(AgeTier.isPreferred(ageDiff)) + } + + @ParameterizedTest + @ValueSource(ints = [6, 7, 10, 20]) + @DisplayName("6살 이상 차이면 선호 범위 아님(false)") + fun nonPreferredRangeReturnsFalse(ageDiff: Int) { + assertFalse(AgeTier.isPreferred(ageDiff)) + } + } + + @Nested + @DisplayName("isCutoff 메서드") + inner class IsCutoffTest { + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3, 4, 5]) + @DisplayName("0~5살 차이면 컷오프 아님(false)") + fun preferredRangeReturnsFalse(ageDiff: Int) { + assertFalse(AgeTier.isCutoff(ageDiff)) + } + + @ParameterizedTest + @ValueSource(ints = [6, 7, 10, 20]) + @DisplayName("6살 이상 차이면 컷오프 대상(true)") + fun cutoffRangeReturnsTrue(ageDiff: Int) { + assertTrue(AgeTier.isCutoff(ageDiff)) + } + } + + @Nested + @DisplayName("우선순위") + inner class PriorityTest { + + @Test + @DisplayName("A1의 우선순위가 가장 높다 (숫자가 작음)") + fun a1HasHighestPriority() { + assertTrue(AgeTier.A1.priority < AgeTier.A2.priority) + assertTrue(AgeTier.A2.priority < AgeTier.A3.priority) + } + + @ParameterizedTest + @CsvSource( + "A1, 1", + "A2, 2", + "A3, 3" + ) + @DisplayName("각 Tier의 우선순위 값 확인") + fun tierPriorityValues(tierName: String, expectedPriority: Int) { + val tier = AgeTier.valueOf(tierName) + assertEquals(expectedPriority, tier.priority) + } + } +} \ No newline at end of file