From d7453476275b62e2bfc5ba8e68950e73d8488a80 Mon Sep 17 00:00:00 2001 From: sgo722 Date: Sat, 24 Jan 2026 20:22:25 +0900 Subject: [PATCH 01/11] =?UTF-8?q?[feat]=20=EC=A7=88=EB=AC=B8=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20QuestionGroup,=20GroupPolicy=20enum=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20QuestionCategory=20=EC=9A=A9=EB=8F=84/?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=EC=A0=95=EC=B1=85=20=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codel/question/domain/GroupPolicy.kt | 21 +++ .../kotlin/codel/question/domain/Question.kt | 18 ++- .../codel/question/domain/QuestionCategory.kt | 126 +++++++++++++++--- .../codel/question/domain/QuestionGroup.kt | 24 ++++ 4 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 src/main/kotlin/codel/question/domain/GroupPolicy.kt create mode 100644 src/main/kotlin/codel/question/domain/QuestionGroup.kt 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 0000000..597da1c --- /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 a367ead..46b6a70 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 973c50d..d9a7a8c 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 0000000..5133b9c --- /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) } + } + } +} From aca8a91e487ca51f4fbb079f5cfb7924fc7126bb Mon Sep 17 00:00:00 2001 From: sgo722 Date: Sat, 24 Jan 2026 20:22:36 +0900 Subject: [PATCH 02/11] =?UTF-8?q?[feat]=20=EC=A7=88=EB=AC=B8=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20DB=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80=20-=20question=5Fgroup=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80,=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20enum=20=ED=99=95=EC=9E=A5,=20=EB=B3=B5?= =?UTF-8?q?=ED=95=A9=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...add_question_group_and_update_category.sql | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/resources/db/migration/V20__add_question_group_and_update_category.sql 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 0000000..83c78c9 --- /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); From 8014b1ba7ee6581d657b96364041b4171892c4a0 Mon Sep 17 00:00:00 2001 From: sgo722 Date: Sat, 24 Jan 2026 20:22:42 +0900 Subject: [PATCH 03/11] =?UTF-8?q?[feat]=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=EC=A7=88=EB=AC=B8=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20-=20A/B=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C,=20=EB=9E=9C=EB=8D=A4=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85,=20Repository=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/QuestionRecommendationResult.kt | 18 +++ .../question/business/QuestionService.kt | 137 +++++++++++++++++- .../infrastructure/QuestionJpaRepository.kt | 62 +++++++- 3 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/codel/question/business/QuestionRecommendationResult.kt 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 0000000..083f3a5 --- /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 501bd9e..c903bdd 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/infrastructure/QuestionJpaRepository.kt b/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt index 1ffa95f..3f7f9f7 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) 제외, 질문하기 버튼 클릭으로 추가된 질문만 집계 From 82f99afe5ec9fd0ae9acf09c5ef7863af39c1837 Mon Sep 17 00:00:00 2001 From: sgo722 Date: Sat, 24 Jan 2026 20:22:47 +0900 Subject: [PATCH 04/11] =?UTF-8?q?[feat]=20=EC=A7=88=EB=AC=B8=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20Strategy=20=ED=8C=A8=ED=84=B4=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20-=20=EB=B2=84=EC=A0=84=EB=B3=84=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0,=20CategoryBased/LegacyRandom=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategy/CategoryBasedQuestionStrategy.kt | 59 ++++++++++++++++ .../strategy/LegacyRandomQuestionStrategy.kt | 45 ++++++++++++ .../strategy/QuestionRecommendStrategy.kt | 27 ++++++++ .../QuestionRecommendStrategyResolver.kt | 69 +++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 src/main/kotlin/codel/chat/business/strategy/CategoryBasedQuestionStrategy.kt create mode 100644 src/main/kotlin/codel/chat/business/strategy/LegacyRandomQuestionStrategy.kt create mode 100644 src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategy.kt create mode 100644 src/main/kotlin/codel/chat/business/strategy/QuestionRecommendStrategyResolver.kt 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 0000000..264315b --- /dev/null +++ b/src/main/kotlin/codel/chat/business/strategy/CategoryBasedQuestionStrategy.kt @@ -0,0 +1,59 @@ +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 + +/** + * 카테고리 기반 질문 추천 전략 (1.3.0 이상) + * + * - 채팅방용 카테고리: 가치관, 텐션업 코드, 만약에 코드, 비밀 코드(19+) + * - A/B 그룹 정책 적용 (텐션업 제외) + */ +@Component +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 0000000..eacaa19 --- /dev/null +++ b/src/main/kotlin/codel/chat/business/strategy/LegacyRandomQuestionStrategy.kt @@ -0,0 +1,45 @@ +package codel.chat.business.strategy + +import codel.chat.business.ChatService +import codel.chat.presentation.request.QuestionRecommendRequest +import codel.chat.presentation.response.QuestionRecommendResponseLegacy +import codel.member.domain.Member +import codel.question.business.QuestionService +import codel.question.presentation.response.QuestionResponse +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component + +/** + * 기존 랜덤 질문 추천 전략 (1.3.0 미만) + * + * - 카테고리 구분 없이 미사용 질문에서 랜덤 추천 + * - 기존 API 응답 형식 유지 + */ +@Component +class LegacyRandomQuestionStrategy( + private val questionService: QuestionService, + private val chatService: ChatService +) : QuestionRecommendStrategy { + + override fun recommendQuestion( + chatRoomId: Long, + member: Member, + request: QuestionRecommendRequest + ): ResponseEntity { + // 기존 로직: 카테고리 무관하게 랜덤 질문 추천 + val unusedQuestions = questionService.findUnusedQuestionsByChatRoom(chatRoomId) + + if (unusedQuestions.isEmpty()) { + return ResponseEntity.ok(QuestionRecommendResponseLegacy.exhausted()) + } + + val selectedQuestion = unusedQuestions.random() + chatService.sendQuestionMessage(chatRoomId, member, selectedQuestion) + + return ResponseEntity.ok( + QuestionRecommendResponseLegacy.success( + question = QuestionResponse.from(selectedQuestion) + ) + ) + } +} 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 0000000..9b6cc18 --- /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 0000000..7d50752 --- /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 이상이면 신규 앱으로 간주 + */ + private 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 + } + } +} From f10643fb249d6f35b724ecf6f819dfb519db3d71 Mon Sep 17 00:00:00 2001 From: sgo722 Date: Sat, 24 Jan 2026 20:22:53 +0900 Subject: [PATCH 05/11] =?UTF-8?q?[feat]=20=EC=A7=88=EB=AC=B8=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20API=20=EC=B6=94=EA=B0=80=20-=20/questions/recommend?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8,=20X-App-Versi?= =?UTF-8?q?on=20=ED=97=A4=EB=8D=94=20=EA=B8=B0=EB=B0=98=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/codel/chat/business/ChatService.kt | 33 +++++++++++++ .../codel/chat/presentation/ChatController.kt | 46 +++++++++++++++++++ .../request/QuestionRecommendRequest.kt | 10 ++++ .../QuestionRecommendResponseLegacy.kt | 30 ++++++++++++ .../response/QuestionRecommendResponseV2.kt | 35 ++++++++++++++ .../swagger/ChatControllerSwagger.kt | 40 ++++++++++++++++ 6 files changed, 194 insertions(+) create mode 100644 src/main/kotlin/codel/chat/presentation/request/QuestionRecommendRequest.kt create mode 100644 src/main/kotlin/codel/chat/presentation/response/QuestionRecommendResponseLegacy.kt create mode 100644 src/main/kotlin/codel/chat/presentation/response/QuestionRecommendResponseV2.kt diff --git a/src/main/kotlin/codel/chat/business/ChatService.kt b/src/main/kotlin/codel/chat/business/ChatService.kt index dee9db1..0c0a578 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/presentation/ChatController.kt b/src/main/kotlin/codel/chat/presentation/ChatController.kt index f778951..c051b39 100644 --- a/src/main/kotlin/codel/chat/presentation/ChatController.kt +++ b/src/main/kotlin/codel/chat/presentation/ChatController.kt @@ -1,12 +1,15 @@ 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.swagger.ChatControllerSwagger import codel.config.Loggable import codel.config.argumentresolver.LoginMember @@ -23,6 +26,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( @@ -91,6 +95,48 @@ class ChatController( return ResponseEntity.ok(result.chatResponse) } + /** + * 질문 추천 API (버전 분기) + * + * - 1.3.0 이상: 카테고리 기반 질문 추천 (CategoryBasedQuestionStrategy) + * - 1.3.0 미만: 기존 랜덤 질문 추천 (LegacyRandomQuestionStrategy) + */ + @PostMapping("/v1/chatroom/{chatRoomId}/questions/recommend") + override fun recommendQuestion( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + @RequestHeader(value = "X-App-Version", required = false) appVersion: String?, + @RequestBody request: QuestionRecommendRequest + ): ResponseEntity { + log.info { "질문 추천 요청 - chatRoomId: $chatRoomId, appVersion: $appVersion, category: ${request.category}" } + + val strategy = strategyResolver.resolveStrategy(appVersion) + val response = strategy.recommendQuestion(chatRoomId, requester, request) + + // V2 응답인 경우 WebSocket 메시지 전송 + if (response.body is QuestionRecommendResponseV2) { + val v2Response = response.body as QuestionRecommendResponseV2 + if (v2Response.success && v2Response.chat != null) { + // 채팅방 실시간 메시지 전송 + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", v2Response.chat.chatResponse) + + // 발송자에게 채팅방 응답 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.getIdOrThrow()}", + v2Response.chat.requesterChatRoomResponse + ) + + // 상대방에게 채팅방 응답 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${v2Response.chat.partner.getIdOrThrow()}", + v2Response.chat.partnerChatRoomResponse + ) + } + } + + return response + } + @PostMapping("/v1/chatroom/{chatRoomId}/chat") fun sendChat( @LoginMember requester: Member, 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 0000000..8cbba55 --- /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/QuestionRecommendResponseLegacy.kt b/src/main/kotlin/codel/chat/presentation/response/QuestionRecommendResponseLegacy.kt new file mode 100644 index 0000000..e4eca22 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/QuestionRecommendResponseLegacy.kt @@ -0,0 +1,30 @@ +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 QuestionRecommendResponseLegacy( + @Schema(description = "추천 성공 여부") + val success: Boolean, + + @Schema(description = "추천된 질문 (소진 시 null)") + val question: QuestionResponse?, + + @Schema(description = "메시지") + val message: String? +) { + companion object { + fun success(question: QuestionResponse) = QuestionRecommendResponseLegacy( + success = true, + question = question, + message = null + ) + + fun exhausted() = QuestionRecommendResponseLegacy( + success = false, + question = null, + message = "추천할 수 있는 질문이 없습니다." + ) + } +} 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 0000000..8d4b5ae --- /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 e832584..509c564 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") @@ -142,6 +144,44 @@ interface ChatControllerSwagger { @PathVariable chatRoomId: Long ): ResponseEntity + @Operation( + summary = "카테고리별 질문 추천", + description = """ + 채팅방에 카테고리 기반 질문을 추천합니다. + + **버전 분기 동작:** + - 1.3.0 이상 (X-App-Version 헤더 필수): 카테고리 기반 질문 추천 + - 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 = "403", description = "채팅방 접근 권한 없음"), + ApiResponse(responseCode = "404", description = "채팅방을 찾을 수 없음"), + ApiResponse(responseCode = "500", description = "서버 내부 오류") + ] + ) + fun recommendQuestion( + @Parameter(hidden = true) @LoginMember requester: Member, + @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 request: QuestionRecommendRequest + ): ResponseEntity + @Operation( summary = "채팅방 대화 종료", description = "지정된 채팅방의 대화를 종료합니다. 요청한 사용자가 해당 채팅방의 참여자여야 합니다." From f97a7dcbf5a79047ff36718e033b668e9cd13b2c Mon Sep 17 00:00:00 2001 From: sgo722 Date: Sat, 24 Jan 2026 20:22:59 +0900 Subject: [PATCH 06/11] =?UTF-8?q?[feat]=20=EC=A7=88=EB=AC=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80=20-=20=EC=9A=A9?= =?UTF-8?q?=EB=8F=84/=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC/=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=ED=95=84=ED=84=B0,=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9C=A0=EC=A7=80,=20=EC=A7=88=EB=AC=B8?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D/=EC=88=98=EC=A0=95=20=ED=8F=BC=EC=97=90?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EC=84=A0=ED=83=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codel/admin/business/AdminService.kt | 28 +++++ .../admin/presentation/AdminController.kt | 20 ++- .../resources/templates/questionEditForm.html | 16 ++- .../resources/templates/questionForm.html | 15 ++- .../resources/templates/questionList.html | 115 ++++++++++++++---- 5 files changed, 165 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/codel/admin/business/AdminService.kt b/src/main/kotlin/codel/admin/business/AdminService.kt index 395bf7c..b4e9a07 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 2dd171f..b37298f 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 @@ -358,16 +359,21 @@ 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("param", mapOf( + 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" @@ -376,6 +382,7 @@ class AdminController( @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}") @@ -405,6 +414,7 @@ class AdminController( val question = adminService.findQuestionById(questionId) model.addAttribute("question", question) model.addAttribute("categories", QuestionCategory.values()) + model.addAttribute("questionGroups", QuestionGroup.values()) return "questionEditForm" } @@ -413,13 +423,15 @@ class AdminController( @PathVariable questionId: Long, @RequestParam content: String, @RequestParam category: String, + @RequestParam questionGroup: String, @RequestParam(required = false) description: String?, @RequestParam(defaultValue = "false") isActive: Boolean, 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}") diff --git a/src/main/resources/templates/questionEditForm.html b/src/main/resources/templates/questionEditForm.html index e6d76b3..a44d0f9 100644 --- a/src/main/resources/templates/questionEditForm.html +++ b/src/main/resources/templates/questionEditForm.html @@ -61,13 +61,25 @@

질문 수정

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