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..b37298fb 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 @@ -320,9 +321,7 @@ class AdminController( "PENDING" to adminService.countMembersByStatus("PENDING"), "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") + "PHONE_VERIFIED" to adminService.countMembersByStatus("PHONE_VERIFIED") ) model.addAttribute("members", members) @@ -360,22 +359,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 +390,15 @@ class AdminController( fun createQuestion( @RequestParam content: String, @RequestParam category: String, + @RequestParam 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) + val group = QuestionGroup.valueOf(questionGroup) + adminService.createQuestionV2(content, questionCategory, group, description, isActive) redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 등록되었습니다.") } catch (e: Exception) { redirectAttributes.addFlashAttribute("error", "질문 등록에 실패했습니다: ${e.message}") @@ -400,21 +409,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 +423,25 @@ class AdminController( @PathVariable questionId: Long, @RequestParam content: String, @RequestParam category: String, + @RequestParam 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) + val group = QuestionGroup.valueOf(questionGroup) + 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 +450,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 +465,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..6e344b02 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,62 @@ 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) + } + response + } + 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..d9a7a8cf 100644 --- a/src/main/kotlin/codel/question/domain/QuestionCategory.kt +++ b/src/main/kotlin/codel/question/domain/QuestionCategory.kt @@ -7,35 +7,119 @@ 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 = true, + chatGroupPolicy = GroupPolicy.A_THEN_B + ), + + // 회원가입 전용 @Schema(description = "취향 관련 질문") - FAVORITE("취향", "취향·관심사·콘텐츠"), - - @Schema(description = "현재 상태 관련 질문") - CURRENT_ME("요즘 나", "최근 상태·몰입한 것"), - + FAVORITE( + displayName = "favorite", + description = "취향·관심사·콘텐츠", + usedInSignup = true, + usedInChat = false, + chatGroupPolicy = GroupPolicy.NONE + ), + + @Schema(description = "현재 상태 관련 질문 (레거시)") + CURRENT_ME( + displayName = "요즘 나", + description = "최근 상태·몰입한 것", + usedInSignup = false, + 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 = "텐션업 코드 - 가벼운 선택 질문") + 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/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/templates/questionEditForm.html b/src/main/resources/templates/questionEditForm.html index 11afb93b..ebbd1cf9 100644 --- a/src/main/resources/templates/questionEditForm.html +++ b/src/main/resources/templates/questionEditForm.html @@ -69,13 +69,25 @@

질문 수정

+
+ + +
A그룹은 우선 추천, B그룹은 A그룹 소진 후 추천됩니다. 텐션업 카테고리는 랜덤을 선택하세요.
+
+