diff --git a/docs/api-specification.yaml b/docs/api-specification.yaml index f26e8a5..ce5c59c 100644 --- a/docs/api-specification.yaml +++ b/docs/api-specification.yaml @@ -521,6 +521,50 @@ paths: aiMessage: $ref: '#/components/schemas/AiChatMessage' + /api/aichat/sessions/{sessionId}/title: + patch: + tags: + - aichat + summary: AI 채팅 세션 제목 수정 + description: AI 채팅 세션의 제목을 사용자가 직접 수정합니다 + parameters: + - name: sessionId + in: path + required: true + schema: + type: integer + format: int64 + description: AI 채팅 세션 ID + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + properties: + title: + type: string + maxLength: 100 + description: 새로운 세션 제목 + responses: + '200': + description: 제목 수정 성공 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/AiChatSession' + '404': + description: 세션을 찾을 수 없음 + '403': + description: 권한 없음 + # =================== # User Chat Domain APIs # =================== diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/AiChatController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/AiChatController.kt new file mode 100644 index 0000000..2af0e76 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/AiChatController.kt @@ -0,0 +1,97 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.controller + +import com.back.koreaTravelGuide.common.ApiResponse +import com.back.koreaTravelGuide.domain.ai.aiChat.dto.AiChatRequest +import com.back.koreaTravelGuide.domain.ai.aiChat.dto.AiChatResponse +import com.back.koreaTravelGuide.domain.ai.aiChat.dto.SessionMessagesResponse +import com.back.koreaTravelGuide.domain.ai.aiChat.dto.SessionsResponse +import com.back.koreaTravelGuide.domain.ai.aiChat.dto.UpdateSessionTitleRequest +import com.back.koreaTravelGuide.domain.ai.aiChat.dto.UpdateSessionTitleResponse +import com.back.koreaTravelGuide.domain.ai.aiChat.service.AiChatService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +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.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/aichat") +class AiChatController( + private val aiChatService: AiChatService, +) { + @GetMapping("/sessions") + fun getSessions( + @RequestParam userId: Long, + ): ResponseEntity>> { + val sessions = + aiChatService.getSessions(userId).map { + SessionsResponse(it.id!!, it.sessionTitle) + } + return ResponseEntity.ok(ApiResponse("채팅방 목록을 성공적으로 조회했습니다.", sessions)) + } + + @PostMapping("/sessions") + fun createSession( + @RequestParam userId: Long, + ): ResponseEntity> { + val session = aiChatService.createSession(userId) + val response = SessionsResponse(session.id!!, session.sessionTitle) + return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 생성되었습니다.", response)) + } + + @DeleteMapping("/sessions/{sessionId}") + fun deleteSession( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + ): ResponseEntity> { + aiChatService.deleteSession(sessionId, userId) + return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 삭제되었습니다.")) + } + + @GetMapping("/sessions/{sessionId}/messages") + fun getSessionMessages( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + ): ResponseEntity>> { + val messages = aiChatService.getSessionMessages(sessionId, userId) + val response = + messages.map { + SessionMessagesResponse(it.content, it.senderType) + } + return ResponseEntity.ok(ApiResponse("채팅 메시지를 성공적으로 조회했습니다.", response)) + } + + @PostMapping("/sessions/{sessionId}/messages") + fun sendMessage( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + @RequestBody request: AiChatRequest, + ): ResponseEntity> { + val (userMessage, aiMessage) = aiChatService.sendMessage(sessionId, userId, request.message) + val response = + AiChatResponse( + userMessage = userMessage.content, + aiMessage = aiMessage.content, + ) + return ResponseEntity.ok(ApiResponse("메시지가 성공적으로 전송되었습니다.", response)) + } + + @PatchMapping("/sessions/{sessionId}/title") + fun updateSessionTitle( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + @RequestBody request: UpdateSessionTitleRequest, + ): ResponseEntity> { + val updatedSession = aiChatService.updateSessionTitle(sessionId, userId, request.newTitle) + val response = + UpdateSessionTitleResponse( + newTitle = updatedSession.sessionTitle, + ) + return ResponseEntity.ok(ApiResponse("채팅방 제목이 성공적으로 수정되었습니다.", response)) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/ChatController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/ChatController.kt deleted file mode 100644 index 9b6c6b1..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/ChatController.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.back.koreaTravelGuide.domain.ai.aiChat.controller - -// TODO: 채팅 컨트롤러 - AI 채팅 API 및 SSE 스트리밍 엔드포인트 제공 -import com.back.koreaTravelGuide.domain.ai.aiChat.tool.WeatherTool -import com.back.koreaTravelGuide.domain.ai.weather.dto.MidForecastDto -import org.springframework.ai.chat.client.ChatClient -import org.springframework.http.MediaType -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter -import reactor.core.Disposable -import reactor.core.scheduler.Schedulers -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter - -@RestController -class ChatController( - private val chatClient: ChatClient, - private val weatherTool: WeatherTool, - // @Value("\${chat.system-prompt}") private val systemPrompt: String, -) { - @GetMapping("/ai") - fun chat( - @RequestParam(defaultValue = "서울 날씨 어때?") question: String, - ): String { - return try { - chatClient.prompt() - .system("한국 여행 전문가 AI입니다. 친근하고 정확한 정보를 제공하세요.") - .user(question) - .call() - .content() ?: "응답을 받을 수 없습니다." - } catch (e: Exception) { - "오류 발생: ${e.message}" - } - } - - @GetMapping("/sse/ai", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) - fun chatSse( - @RequestParam q: String, - ): SseEmitter { - // 타임아웃 무제한(프록시/로드밸런서 idle 타임아웃은 별도 고려 필요) - val emitter = SseEmitter(0L) - - // Spring AI: Flux (토큰/청크 단위 문자열) - val flux = - chatClient - .prompt() // 프롬프트 빌더 시작 - .system("한국 여행 전문가 AI입니다. 친근하고 정확한 정보를 제공하세요.") // 시스템 프롬프트 설정 - .user(q) // 사용자 메시지 설정 - .stream() // 스트리밍 모드 (Flux 반환) - .content() // 텍스트만 추출(Flux) - - // 구독 핸들 저장해서 커넥션 종료 시 해제 - lateinit var disposable: Disposable - - // 서블릿 스레드 점유 방지용 스케줄러 (I/O/네트워크는 boundedElastic 권장) - disposable = - flux - .publishOn(Schedulers.boundedElastic()) - .doOnCancel { - // 클라이언트가 EventSource 닫으면 여기로 들어올 수 있음 - emitter.complete() - } - .subscribe( - { chunk -> - // 각 토큰을 SSE 이벤트로 전송 - // event: message \n data: \n\n - try { - // 필요 시 커스텀 이벤트명 - emitter.send( - SseEmitter.event() - .name("message") - // data 필드에 토큰 추가 - .data(chunk), - ) - } catch (e: Exception) { - // 네트워크 끊김 등으로 전송 실패 시 구독 해제 및 종료 - disposable.dispose() - emitter.completeWithError(e) - } - }, - { e -> - // 에러 이벤트 전송 후 종료 - try { - emitter.send( - SseEmitter.event() - .name("error") - .data("[ERROR] ${e.message}"), - ) - } finally { - emitter.completeWithError(e) - } - }, - { - // 완료 이벤트 전송 후 종료 - try { - emitter.send( - SseEmitter.event() - .name("done") - .data("[DONE]"), - ) - } finally { - emitter.complete() - } - }, - ) - - // 서버/클라이언트 쪽에서 완료/타임아웃 시 정리 - emitter.onCompletion { disposable.dispose() } - emitter.onTimeout { - disposable.dispose() - emitter.complete() - } - - return emitter - } - - // 날씨 API 직접 테스트용 엔드포인트 - @GetMapping("/weather/test") - fun testWeather( - @RequestParam(required = false) baseTime: String?, - ): List? { - return weatherTool.queryMidTermNarrative( - baseTime = baseTime, - ) - } - - // 지역별 날씨 간단 조회 -// @GetMapping("/weather/simple") -// fun simpleWeather( -// @RequestParam(defaultValue = "서울") location: String, -// ): String { -// val response = -// weatherTool.getWeatherForecast( -// location = location, -// regionCode = null, -// baseTime = null, -// ) -// -// return """ -// |지역: ${response.region} -// |지역코드: ${response.regionCode} -// |발표시각: ${response.baseTime} -// | -// |${response.forecast} -// """.trimMargin() -// } - - // 현재 서버 시간 확인용 엔드포인트 - @GetMapping("/time/current") - fun getCurrentTime(): Map { - val now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")) - return mapOf( - "current_kst_time" to now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), - "timezone" to "Asia/Seoul", - "timestamp" to System.currentTimeMillis().toString(), - ) - } - - // 원시 XML 응답 확인용 엔드포인트 -// @GetMapping("/weather/debug") -// fun debugWeatherApi( -// @RequestParam(defaultValue = "서울") location: String, -// @RequestParam(required = false) regionCode: String?, -// @RequestParam(required = false) baseTime: String?, -// ): Map { -// return try { -// println("🚀 디버그 API 호출 시작 - location: $location") -// val response = -// weatherTool.getWeatherForecast( -// location = location, -// regionCode = regionCode, -// baseTime = baseTime, -// ) -// -// mapOf( -// "success" to true, -// "location" to location, -// "regionCode" to (regionCode ?: "자동변환"), -// "baseTime" to (baseTime ?: "자동계산"), -// "response" to response, -// "hasData" to (response.details.day4 != null || response.details.day5 != null), -// "message" to "디버그 정보가 콘솔에 출력되었습니다.", -// ) -// } catch (e: Exception) { -// mapOf( -// "success" to false, -// "error" to (e.message ?: "알 수 없는 오류"), -// "location" to location, -// "message" to "오류 발생: ${e.message ?: "알 수 없는 오류"}", -// ) -// } -// } -} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatRequest.kt new file mode 100644 index 0000000..94b0e24 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatRequest.kt @@ -0,0 +1,5 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.dto + +data class AiChatRequest( + val message: String, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatResponse.kt new file mode 100644 index 0000000..bf547a8 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatResponse.kt @@ -0,0 +1,6 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.dto + +data class AiChatResponse( + val userMessage: String, + val aiMessage: String, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatRequest.kt deleted file mode 100644 index eed0011..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatRequest.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.back.koreaTravelGuide.domain.ai.aiChat.dto - -// TODO: 채팅 요청 DTO - 사용자 메시지 및 옵션 전달 -data class ChatRequest( - val message: String, -) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatResponse.kt deleted file mode 100644 index f6121d4..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatResponse.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.back.koreaTravelGuide.domain.ai.aiChat.dto - -// TODO: 채팅 응답 DTO - AI 답변 및 메타데이터 반환 -data class ChatResponse( - val message: String, - val timestamp: String, -) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/DeleteAiChatRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/DeleteAiChatRequest.kt new file mode 100644 index 0000000..0022aef --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/DeleteAiChatRequest.kt @@ -0,0 +1,5 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.dto + +data class DeleteAiChatRequest( + val sessionId: Long, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionMessagesResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionMessagesResponse.kt new file mode 100644 index 0000000..44e8d6f --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionMessagesResponse.kt @@ -0,0 +1,8 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.dto + +import com.back.koreaTravelGuide.domain.ai.aiChat.entity.SenderType + +data class SessionMessagesResponse( + val content: String, + val senderType: SenderType, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionsResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionsResponse.kt new file mode 100644 index 0000000..b978a00 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionsResponse.kt @@ -0,0 +1,6 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.dto + +data class SessionsResponse( + val sessionId: Long, + val sessionTitle: String, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleRequest.kt new file mode 100644 index 0000000..1ef831d --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleRequest.kt @@ -0,0 +1,5 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.dto + +data class UpdateSessionTitleRequest( + val newTitle: String, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleResponse.kt new file mode 100644 index 0000000..3f7b59b --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleResponse.kt @@ -0,0 +1,5 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.dto + +data class UpdateSessionTitleResponse( + val newTitle: String, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/entity/AiChatSession.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/entity/AiChatSession.kt index bbf9fe3..bd5c878 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/entity/AiChatSession.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/entity/AiChatSession.kt @@ -18,8 +18,8 @@ class AiChatSession( val id: Long? = null, @Column(name = "user_id", nullable = false) val userId: Long, - @Column(name = "session_title", nullable = true, length = 100) - var sessionTitle: String? = null, + @Column(name = "session_title", nullable = false, length = 100) + var sessionTitle: String, @Column(name = "created_at", nullable = false) val createdAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")), ) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatMessageRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatMessageRepository.kt index febd009..f315cdd 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatMessageRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatMessageRepository.kt @@ -2,7 +2,11 @@ package com.back.koreaTravelGuide.domain.ai.aiChat.repository import com.back.koreaTravelGuide.domain.ai.aiChat.entity.AiChatMessage import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +@Repository interface AiChatMessageRepository : JpaRepository { fun findByAiChatSessionIdOrderByCreatedAtAsc(sessionId: Long): List + + fun countByAiChatSessionId(sessionId: Long): Long } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatSessionRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatSessionRepository.kt index 710bc84..770f293 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatSessionRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/repository/AiChatSessionRepository.kt @@ -2,7 +2,9 @@ package com.back.koreaTravelGuide.domain.ai.aiChat.repository import com.back.koreaTravelGuide.domain.ai.aiChat.entity.AiChatSession import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +@Repository interface AiChatSessionRepository : JpaRepository { // 사용자별 세션 조회 (최신순) fun findByUserIdOrderByCreatedAtDesc(userId: Long): List diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/service/AiChatService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/service/AiChatService.kt index cc15804..322a6a9 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/service/AiChatService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/service/AiChatService.kt @@ -22,7 +22,7 @@ class AiChatService( } fun createSession(userId: Long): AiChatSession { - val newSession = AiChatSession(userId = userId) + val newSession = AiChatSession(userId = userId, sessionTitle = "새로운 채팅방") return aiChatSessionRepository.save(newSession) } @@ -30,9 +30,7 @@ class AiChatService( sessionId: Long, userId: Long, ) { - val session = - aiChatSessionRepository.findByIdAndUserId(sessionId, userId) - ?: throw IllegalArgumentException("해당 채팅방이 없거나 삭제 권한이 없습니다.") + val session = getSessionWithOwnershipCheck(sessionId, userId) aiChatSessionRepository.deleteById(sessionId) } @@ -41,9 +39,7 @@ class AiChatService( sessionId: Long, userId: Long, ): List { - val session = - aiChatSessionRepository.findByIdAndUserId(sessionId, userId) - ?: throw IllegalArgumentException("해당 채팅방이 없거나 접근 권한이 없습니다.") + val session = getSessionWithOwnershipCheck(sessionId, userId) return aiChatMessageRepository.findByAiChatSessionIdOrderByCreatedAtAsc(sessionId) } @@ -53,9 +49,7 @@ class AiChatService( userId: Long, message: String, ): Pair { - val session = - aiChatSessionRepository.findByIdAndUserId(sessionId, userId) - ?: throw IllegalArgumentException("해당 채팅방이 없거나 접근 권한이 없습니다.") + val session = getSessionWithOwnershipCheck(sessionId, userId) val userMessage = AiChatMessage( @@ -65,6 +59,10 @@ class AiChatService( ) val savedUserMessage = aiChatMessageRepository.save(userMessage) + if (aiChatMessageRepository.countByAiChatSessionId(sessionId) == 1L) { + aiUpdateSessionTitle(session, message) + } + val response = try { chatClient.prompt() @@ -88,4 +86,47 @@ class AiChatService( val savedAiMessage = aiChatMessageRepository.save(aiMessage) return Pair(savedUserMessage, savedAiMessage) } + + fun updateSessionTitle( + sessionId: Long, + userId: Long, + newTitle: String, + ): AiChatSession { + val session = getSessionWithOwnershipCheck(sessionId, userId) + session.sessionTitle = newTitle.trim().take(100) + return aiChatSessionRepository.save(session) + } + + /** + * AiChatService 내 헬퍼 메서드들을 정의합니다. + * + * aiUpdateSessionTitle - AI를 사용하여 기본 채팅방 제목을 업데이트합니다. + * + * checkSessionOwnership - 세션 소유권을 확인합니다. + */ + private fun aiUpdateSessionTitle( + session: AiChatSession, + userMessage: String, + ) { + val newTitle = + try { + chatClient.prompt() + .system("사용자의 채팅방 제목을 메시지를 요약해서 해당 사용자의 언어로 간결하게 만들어줘.") + .user(userMessage) + .call() + .content() ?: session.sessionTitle + } catch (e: Exception) { + session.sessionTitle + } + session.sessionTitle = newTitle.take(100) + aiChatSessionRepository.save(session) + } + + private fun getSessionWithOwnershipCheck( + sessionId: Long, + userId: Long, + ): AiChatSession { + return aiChatSessionRepository.findByIdAndUserId(sessionId, userId) + ?: throw IllegalArgumentException("해당 채팅방이 없거나 접근 권한이 없습니다.") + } }