Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
d745347
[feat] 질문 그룹 도메인 모델 추가 - QuestionGroup, GroupPolicy enum 생성, Question…
sgo722 Jan 24, 2026
aca8a91
[feat] 질문 그룹 DB 마이그레이션 추가 - question_group 컬럼 추가, 카테고리 enum 확장, 복합 인덱…
sgo722 Jan 24, 2026
8014b1b
[feat] 카테고리별 질문 추천 로직 구현 - A/B 그룹 우선순위 추천, 랜덤 추천 정책, Repository 쿼리 추가
sgo722 Jan 24, 2026
82f99af
[feat] 질문 추천 Strategy 패턴 구현 - 버전별 전략 분기, CategoryBased/LegacyRandom 전…
sgo722 Jan 24, 2026
f10643f
[feat] 질문 추천 API 추가 - /questions/recommend 엔드포인트, X-App-Version 헤더 기반…
sgo722 Jan 24, 2026
f97a7dc
[feat] 질문 관리 페이지 그룹 필터 추가 - 용도/카테고리/그룹 필터, 검색 상태 유지, 질문 등록/수정 폼에 그룹 선…
sgo722 Jan 24, 2026
e98fe1c
[test] 질문 추천 기능 테스트 추가 - StrategyResolver, QuestionCategory, Question…
sgo722 Jan 24, 2026
42a2633
[fix] PreVerificationStrategyTest 초기화 오류 수정 - asyncNotificationServic…
sgo722 Jan 24, 2026
2b127ab
[refactor] 질문 추천 API 통합 - 기존 /questions/random 엔드포인트에 버전 분기 적용, /ques…
sgo722 Jan 24, 2026
dd4e564
[refactor] 전략 패턴 통일 및 트랜잭션 경계 수정 - 레거시/신규 모두 Strategy 패턴 사용, @Transac…
sgo722 Jan 26, 2026
337e3ed
[refactor] 미사용 QuestionRecommendResponseLegacy 클래스 삭제
sgo722 Jan 26, 2026
97a6127
Merge branch 'develop' into feature/#389
sgo722 Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}
28 changes: 28 additions & 0 deletions src/main/kotlin/codel/admin/business/AdminService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -290,6 +291,14 @@ class AdminService(
isActive: Boolean?,
pageable: Pageable
): Page<Question> = questionService.findQuestionsWithFilter(keyword, category, isActive, pageable)

fun findQuestionsWithFilterV2(
keyword: String?,
category: String?,
questionGroup: String?,
isActive: Boolean?,
pageable: Pageable
): Page<Question> = questionService.findQuestionsWithFilterV2(keyword, category, questionGroup, isActive, pageable)

fun findQuestionById(questionId: Long): Question = questionService.findQuestionById(questionId)

Expand All @@ -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(
Expand All @@ -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)
Expand Down
91 changes: 24 additions & 67 deletions src/main/kotlin/codel/admin/presentation/AdminController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -360,36 +359,46 @@ 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"
}

@PostMapping("/v1/admin/questions")
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}")
Expand All @@ -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"
}

Expand All @@ -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<String>()
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 {
Expand All @@ -468,27 +450,12 @@ class AdminController(
} catch (e: Exception) {
redirectAttributes.addFlashAttribute("error", "질문 삭제에 실패했습니다: ${e.message}")
}

// 필터 조건 유지하여 리다이렉트
val params = mutableListOf<String>()
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 {
Expand All @@ -498,17 +465,7 @@ class AdminController(
} catch (e: Exception) {
redirectAttributes.addFlashAttribute("error", "질문 상태 변경에 실패했습니다: ${e.message}")
}

// 필터 조건 유지하여 리다이렉트
val params = mutableListOf<String>()
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"
}

// ========== 신고 관리 ==========
Expand Down
33 changes: 33 additions & 0 deletions src/main/kotlin/codel/chat/business/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

/**
* 채팅방 멤버 권한 검증
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Any> {
// 카테고리 필수 검증
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())
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Any> {
val result = chatService.sendRandomQuestion(chatRoomId, member)
return ResponseEntity.ok(result)
}
}
Loading