Skip to content

Commit f720b40

Browse files
authored
Merge pull request #399 from CODE-LG/develop
배포 1.4.0
2 parents bee08a0 + 5f47e50 commit f720b40

43 files changed

Lines changed: 2935 additions & 430 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(git add:*)",
5+
"Bash(git commit:*)"
6+
]
7+
}
8+
}

src/main/kotlin/codel/admin/business/AdminService.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import codel.notification.domain.NotificationType
1414
import codel.question.business.QuestionService
1515
import codel.question.domain.Question
1616
import codel.question.domain.QuestionCategory
17+
import codel.question.domain.QuestionGroup
1718
import codel.verification.domain.StandardVerificationImage
1819
import codel.verification.domain.VerificationImage
1920
import codel.verification.infrastructure.StandardVerificationImageJpaRepository
@@ -290,6 +291,14 @@ class AdminService(
290291
isActive: Boolean?,
291292
pageable: Pageable
292293
): Page<Question> = questionService.findQuestionsWithFilter(keyword, category, isActive, pageable)
294+
295+
fun findQuestionsWithFilterV2(
296+
keyword: String?,
297+
category: String?,
298+
questionGroup: String?,
299+
isActive: Boolean?,
300+
pageable: Pageable
301+
): Page<Question> = questionService.findQuestionsWithFilterV2(keyword, category, questionGroup, isActive, pageable)
293302

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

@@ -300,6 +309,15 @@ class AdminService(
300309
description: String?,
301310
isActive: Boolean
302311
): Question = questionService.createQuestion(content, category, description, isActive)
312+
313+
@Transactional
314+
fun createQuestionV2(
315+
content: String,
316+
category: QuestionCategory,
317+
questionGroup: QuestionGroup,
318+
description: String?,
319+
isActive: Boolean
320+
): Question = questionService.createQuestionV2(content, category, questionGroup, description, isActive)
303321

304322
@Transactional
305323
fun updateQuestion(
@@ -309,6 +327,16 @@ class AdminService(
309327
description: String?,
310328
isActive: Boolean
311329
): Question = questionService.updateQuestion(questionId, content, category, description, isActive)
330+
331+
@Transactional
332+
fun updateQuestionV2(
333+
questionId: Long,
334+
content: String,
335+
category: QuestionCategory,
336+
questionGroup: QuestionGroup,
337+
description: String?,
338+
isActive: Boolean
339+
): Question = questionService.updateQuestionV2(questionId, content, category, questionGroup, description, isActive)
312340

313341
@Transactional
314342
fun deleteQuestion(questionId: Long) = questionService.deleteQuestion(questionId)

src/main/kotlin/codel/admin/presentation/AdminController.kt

Lines changed: 35 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import codel.admin.presentation.request.AdminLoginRequest
99
import codel.admin.presentation.request.RejectProfileRequest
1010
import codel.member.domain.Member
1111
import codel.question.domain.QuestionCategory
12+
import codel.question.domain.QuestionGroup
1213
import jakarta.servlet.http.Cookie
1314
import jakarta.servlet.http.HttpServletResponse
1415
import org.springframework.data.domain.Page
@@ -321,8 +322,8 @@ class AdminController(
321322
"DONE" to adminService.countMembersByStatus("DONE"),
322323
"REJECT" to adminService.countMembersByStatus("REJECT"),
323324
"PHONE_VERIFIED" to adminService.countMembersByStatus("PHONE_VERIFIED"),
324-
"WITHDRAWN" to adminService.countMembersByStatus("WITHDRAWN"),
325-
"PERSONALITY_COMPLETED" to adminService.countMembersByStatus("PERSONALITY_COMPLETED")
325+
"PERSONALITY_COMPLETED" to adminService.countMembersByStatus("PERSONALITY_COMPLETED"),
326+
"WITHDRAWN" to adminService.countMembersByStatus("WITHDRAWN")
326327
)
327328

328329
model.addAttribute("members", members)
@@ -360,36 +361,51 @@ class AdminController(
360361
fun questionList(
361362
model: Model,
362363
@RequestParam(required = false) keyword: String?,
364+
@RequestParam(required = false) purpose: String?,
363365
@RequestParam(required = false) category: String?,
366+
@RequestParam(required = false) questionGroup: String?,
364367
@RequestParam(required = false) isActive: Boolean?,
365368
@PageableDefault(size = 20) pageable: Pageable
366369
): String {
367-
val questions = adminService.findQuestionsWithFilter(keyword, category, isActive, pageable)
370+
val questions = adminService.findQuestionsWithFilterV2(keyword, category, questionGroup, isActive, pageable)
368371
model.addAttribute("questions", questions)
369372
model.addAttribute("categories", QuestionCategory.values())
370-
model.addAttribute("selectedKeyword", keyword ?: "")
371-
model.addAttribute("selectedCategory", category ?: "")
372-
model.addAttribute("selectedIsActive", isActive?.toString() ?: "")
373+
model.addAttribute("questionGroups", QuestionGroup.values())
374+
model.addAttribute("searchParams", mapOf(
375+
"keyword" to (keyword ?: ""),
376+
"purpose" to (purpose ?: ""),
377+
"category" to (category ?: ""),
378+
"questionGroup" to (questionGroup ?: ""),
379+
"isActive" to (isActive?.toString() ?: "")
380+
))
373381
return "questionList"
374382
}
375383

376384
@GetMapping("/v1/admin/questions/new")
377385
fun questionForm(model: Model): String {
378386
model.addAttribute("categories", QuestionCategory.values())
387+
model.addAttribute("questionGroups", QuestionGroup.values())
379388
return "questionForm"
380389
}
381390

382391
@PostMapping("/v1/admin/questions")
383392
fun createQuestion(
384393
@RequestParam content: String,
385394
@RequestParam category: String,
395+
@RequestParam(required = false) questionGroup: String?,
386396
@RequestParam(required = false) description: String?,
387397
@RequestParam(defaultValue = "true") isActive: Boolean,
388398
redirectAttributes: RedirectAttributes
389399
): String {
390400
try {
391401
val questionCategory = QuestionCategory.valueOf(category)
392-
adminService.createQuestion(content, questionCategory, description, isActive)
402+
// 회원가입 전용 카테고리(채팅 미사용)는 자동으로 RANDOM 그룹 지정
403+
val group = if (questionCategory.usedInSignup && !questionCategory.usedInChat) {
404+
QuestionGroup.RANDOM
405+
} else {
406+
QuestionGroup.valueOf(questionGroup ?: "RANDOM")
407+
}
408+
adminService.createQuestionV2(content, questionCategory, group, description, isActive)
393409
redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 등록되었습니다.")
394410
} catch (e: Exception) {
395411
redirectAttributes.addFlashAttribute("error", "질문 등록에 실패했습니다: ${e.message}")
@@ -400,21 +416,12 @@ class AdminController(
400416
@GetMapping("/v1/admin/questions/{questionId}/edit")
401417
fun editQuestionForm(
402418
@PathVariable questionId: Long,
403-
@RequestParam(required = false) keyword: String?,
404-
@RequestParam(required = false) category: String?,
405-
@RequestParam(required = false) isActive: String?,
406-
@RequestParam(required = false, defaultValue = "0") page: Int,
407-
@RequestParam(required = false, defaultValue = "20") size: Int,
408419
model: Model
409420
): String {
410421
val question = adminService.findQuestionById(questionId)
411422
model.addAttribute("question", question)
412423
model.addAttribute("categories", QuestionCategory.values())
413-
model.addAttribute("filterKeyword", keyword)
414-
model.addAttribute("filterCategory", category)
415-
model.addAttribute("filterIsActive", isActive)
416-
model.addAttribute("filterPage", page)
417-
model.addAttribute("filterSize", size)
424+
model.addAttribute("questionGroups", QuestionGroup.values())
418425
return "questionEditForm"
419426
}
420427

@@ -423,43 +430,30 @@ class AdminController(
423430
@PathVariable questionId: Long,
424431
@RequestParam content: String,
425432
@RequestParam category: String,
433+
@RequestParam(required = false) questionGroup: String?,
426434
@RequestParam(required = false) description: String?,
427435
@RequestParam(defaultValue = "false") isActive: Boolean,
428-
@RequestParam(required = false) keyword: String?,
429-
@RequestParam(required = false) filterCategory: String?,
430-
@RequestParam(required = false) filterIsActive: String?,
431-
@RequestParam(required = false, defaultValue = "0") page: Int,
432-
@RequestParam(required = false, defaultValue = "20") size: Int,
433436
redirectAttributes: RedirectAttributes
434437
): String {
435438
try {
436439
val questionCategory = QuestionCategory.valueOf(category)
437-
adminService.updateQuestion(questionId, content, questionCategory, description, isActive)
440+
// 회원가입 전용 카테고리(채팅 미사용)는 자동으로 RANDOM 그룹 지정
441+
val group = if (questionCategory.usedInSignup && !questionCategory.usedInChat) {
442+
QuestionGroup.RANDOM
443+
} else {
444+
QuestionGroup.valueOf(questionGroup ?: "RANDOM")
445+
}
446+
adminService.updateQuestionV2(questionId, content, questionCategory, group, description, isActive)
438447
redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 수정되었습니다.")
439448
} catch (e: Exception) {
440449
redirectAttributes.addFlashAttribute("error", "질문 수정에 실패했습니다: ${e.message}")
441450
}
442-
443-
// 필터 조건 유지하여 리다이렉트
444-
val params = mutableListOf<String>()
445-
keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") }
446-
filterCategory?.let { if (it.isNotBlank()) params.add("category=$it") }
447-
filterIsActive?.let { if (it.isNotBlank()) params.add("isActive=$it") }
448-
params.add("page=$page")
449-
params.add("size=$size")
450-
451-
val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else ""
452-
return "redirect:/v1/admin/questions$queryString"
451+
return "redirect:/v1/admin/questions"
453452
}
454453

455454
@PostMapping("/v1/admin/questions/{questionId}/delete")
456455
fun deleteQuestion(
457456
@PathVariable questionId: Long,
458-
@RequestParam(required = false) keyword: String?,
459-
@RequestParam(required = false) category: String?,
460-
@RequestParam(required = false) isActive: String?,
461-
@RequestParam(required = false, defaultValue = "0") page: Int,
462-
@RequestParam(required = false, defaultValue = "20") size: Int,
463457
redirectAttributes: RedirectAttributes
464458
): String {
465459
try {
@@ -468,27 +462,12 @@ class AdminController(
468462
} catch (e: Exception) {
469463
redirectAttributes.addFlashAttribute("error", "질문 삭제에 실패했습니다: ${e.message}")
470464
}
471-
472-
// 필터 조건 유지하여 리다이렉트
473-
val params = mutableListOf<String>()
474-
keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") }
475-
category?.let { if (it.isNotBlank()) params.add("category=$it") }
476-
isActive?.let { if (it.isNotBlank()) params.add("isActive=$it") }
477-
params.add("page=$page")
478-
params.add("size=$size")
479-
480-
val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else ""
481-
return "redirect:/v1/admin/questions$queryString"
465+
return "redirect:/v1/admin/questions"
482466
}
483467

484468
@PostMapping("/v1/admin/questions/{questionId}/toggle")
485469
fun toggleQuestionStatus(
486470
@PathVariable questionId: Long,
487-
@RequestParam(required = false) keyword: String?,
488-
@RequestParam(required = false) category: String?,
489-
@RequestParam(required = false) isActive: String?,
490-
@RequestParam(required = false, defaultValue = "0") page: Int,
491-
@RequestParam(required = false, defaultValue = "20") size: Int,
492471
redirectAttributes: RedirectAttributes
493472
): String {
494473
try {
@@ -498,17 +477,7 @@ class AdminController(
498477
} catch (e: Exception) {
499478
redirectAttributes.addFlashAttribute("error", "질문 상태 변경에 실패했습니다: ${e.message}")
500479
}
501-
502-
// 필터 조건 유지하여 리다이렉트
503-
val params = mutableListOf<String>()
504-
keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") }
505-
category?.let { if (it.isNotBlank()) params.add("category=$it") }
506-
isActive?.let { if (it.isNotBlank()) params.add("isActive=$it") }
507-
params.add("page=$page")
508-
params.add("size=$size")
509-
510-
val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else ""
511-
return "redirect:/v1/admin/questions$queryString"
480+
return "redirect:/v1/admin/questions"
512481
}
513482

514483
// ========== 신고 관리 ==========

src/main/kotlin/codel/chat/business/ChatService.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,39 @@ class ChatService(
495495
return buildQuestionSendResult(requester, partner, savedChat)
496496
}
497497

498+
/**
499+
* 특정 질문을 채팅방에 전송 (Strategy 패턴용)
500+
*
501+
* @param chatRoomId 채팅방 ID
502+
* @param requester 요청 회원
503+
* @param question 전송할 질문
504+
* @return 저장된 채팅 정보
505+
*/
506+
fun sendQuestionMessage(chatRoomId: Long, requester: Member, question: Question): SavedChatDto {
507+
// 1. 채팅방 검증
508+
val chatRoom = chatRoomJpaRepository.findById(chatRoomId)
509+
.orElseThrow { ChatException(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다.") }
510+
511+
validateChatRoomMember(chatRoomId, requester)
512+
val partner = findPartner(chatRoomId, requester)
513+
514+
// 2. 질문 사용 표시
515+
questionService.markQuestionAsUsed(chatRoomId, question, requester)
516+
517+
// 3. 채팅 메시지 생성
518+
val savedChat = createQuestionSystemMessage(chatRoom, question, requester)
519+
chatRoom.updateRecentChat(savedChat)
520+
521+
// 4. 결과 반환
522+
val result = buildQuestionSendResult(requester, partner, savedChat)
523+
return SavedChatDto(
524+
partner = result.partner,
525+
requesterChatRoomResponse = result.requesterChatRoomResponse,
526+
partnerChatRoomResponse = result.partnerChatRoomResponse,
527+
chatResponse = result.chatResponse
528+
)
529+
}
530+
498531
/**
499532
* 채팅방 멤버 권한 검증
500533
*/
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package codel.chat.business.strategy
2+
3+
import codel.chat.business.ChatService
4+
import codel.chat.presentation.request.QuestionRecommendRequest
5+
import codel.chat.presentation.response.QuestionRecommendResponseV2
6+
import codel.member.domain.Member
7+
import codel.question.business.QuestionRecommendationResult
8+
import codel.question.business.QuestionService
9+
import codel.question.presentation.response.QuestionResponse
10+
import org.springframework.http.ResponseEntity
11+
import org.springframework.stereotype.Component
12+
import org.springframework.transaction.annotation.Transactional
13+
14+
/**
15+
* 카테고리 기반 질문 추천 전략 (1.3.0 이상)
16+
*
17+
* - 채팅방용 카테고리: 가치관, 텐션업 코드, 만약에 코드, 비밀 코드(19+)
18+
* - A/B 그룹 정책 적용 (텐션업 제외)
19+
*/
20+
@Component
21+
@Transactional
22+
class CategoryBasedQuestionStrategy(
23+
private val questionService: QuestionService,
24+
private val chatService: ChatService
25+
) : QuestionRecommendStrategy {
26+
27+
override fun recommendQuestion(
28+
chatRoomId: Long,
29+
member: Member,
30+
request: QuestionRecommendRequest
31+
): ResponseEntity<Any> {
32+
// 카테고리 필수 검증
33+
val category = request.category
34+
?: return ResponseEntity.badRequest()
35+
.body(mapOf("message" to "카테고리를 선택해주세요."))
36+
37+
// 채팅방용 카테고리 검증
38+
if (!category.isChatCategory()) {
39+
return ResponseEntity.badRequest()
40+
.body(mapOf("message" to "채팅방에서 사용할 수 없는 카테고리입니다."))
41+
}
42+
43+
// 카테고리별 정책에 따른 질문 추천
44+
val result = questionService.recommendQuestionForChat(chatRoomId, category)
45+
46+
return when (result) {
47+
is QuestionRecommendationResult.Success -> {
48+
val savedChat = chatService.sendQuestionMessage(chatRoomId, member, result.question)
49+
ResponseEntity.ok(
50+
QuestionRecommendResponseV2.success(
51+
question = QuestionResponse.from(result.question),
52+
chat = savedChat
53+
)
54+
)
55+
}
56+
is QuestionRecommendationResult.Exhausted -> {
57+
ResponseEntity.ok(QuestionRecommendResponseV2.exhausted())
58+
}
59+
}
60+
}
61+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package codel.chat.business.strategy
2+
3+
import codel.chat.business.ChatService
4+
import codel.chat.presentation.request.QuestionRecommendRequest
5+
import codel.chat.presentation.response.QuestionSendResult
6+
import codel.member.domain.Member
7+
import org.springframework.http.ResponseEntity
8+
import org.springframework.stereotype.Component
9+
import org.springframework.transaction.annotation.Transactional
10+
11+
/**
12+
* 기존 랜덤 질문 추천 전략 (1.3.0 미만)
13+
*
14+
* - 카테고리 구분 없이 미사용 질문에서 랜덤 추천
15+
* - 기존 API 응답 형식 유지 (ChatResponse)
16+
*/
17+
@Component
18+
@Transactional
19+
class LegacyRandomQuestionStrategy(
20+
private val chatService: ChatService
21+
) : QuestionRecommendStrategy {
22+
23+
override fun recommendQuestion(
24+
chatRoomId: Long,
25+
member: Member,
26+
request: QuestionRecommendRequest
27+
): ResponseEntity<Any> {
28+
val result = chatService.sendRandomQuestion(chatRoomId, member)
29+
return ResponseEntity.ok(result)
30+
}
31+
}

0 commit comments

Comments
 (0)