From 4f3ae204d442e7ce41f043eae6b9128bb3060aa0 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 21 Aug 2025 21:57:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=20=20-=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=8A=A4=ED=8E=99=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20->=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20body=EC=97=90=20type?= =?UTF-8?q?=20=EB=84=A3=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=20=20-=20'/'=EB=A1=9C=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B1=97=EB=B4=87=20=EA=B8=B0=EB=8A=A5=20=EC=9E=91?= =?UTF-8?q?=EB=8F=99=20=20=20-=20openAi=20service=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1(=20openai=20api=20=ED=98=B8=EC=B6=9C=20)=20?= =?UTF-8?q?=20=20-=20redis=EC=97=90=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=BA=90=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../busanVibe/busan/BusanApplication.kt | 11 +- .../chat/controller/ChatRestController.kt | 19 +- .../domain/chat/dto/openai/ChatRequest.kt | 15 -- .../busan/domain/chat/dto/openai/OpenAiDTO.kt | 40 ++++ .../chat/dto/websocket/ChatMessageSendDTO.kt | 8 +- .../domain/chat/service/ChatGPTService.kt | 43 ----- .../domain/chat/service/ChatMongoService.kt | 89 +++++++-- .../domain/chat/service/OpenAiService.kt | 174 ++++++++++++++++++ .../domain/chat/service/RedisSubscriber.kt | 4 +- .../busan/global/config/WebClientConfig.kt | 16 +- .../AuthChannelInterceptorAdapter.kt | 7 +- .../webSocket/JwtHandshakeInterceptor.kt | 51 ----- .../config/webSocket/WebSocketConfig.kt | 5 +- 13 files changed, 333 insertions(+), 149 deletions(-) delete mode 100644 src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/ChatRequest.kt create mode 100644 src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/OpenAiDTO.kt delete mode 100644 src/main/kotlin/busanVibe/busan/domain/chat/service/ChatGPTService.kt create mode 100644 src/main/kotlin/busanVibe/busan/domain/chat/service/OpenAiService.kt delete mode 100644 src/main/kotlin/busanVibe/busan/global/config/webSocket/JwtHandshakeInterceptor.kt diff --git a/src/main/kotlin/busanVibe/busan/BusanApplication.kt b/src/main/kotlin/busanVibe/busan/BusanApplication.kt index b34487d..26df6ce 100644 --- a/src/main/kotlin/busanVibe/busan/BusanApplication.kt +++ b/src/main/kotlin/busanVibe/busan/BusanApplication.kt @@ -1,12 +1,21 @@ package busanVibe.busan +import org.springframework.boot.CommandLineRunner import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.context.annotation.Bean import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import java.util.TimeZone @SpringBootApplication @EnableJpaAuditing -class BusanApplication +class BusanApplication{ + @Bean + fun init() = CommandLineRunner { + // 서버 타임존 설정 + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} fun main(args: Array) { runApplication(*args) diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt index b8151b9..9aa9e50 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt @@ -1,5 +1,6 @@ package busanVibe.busan.domain.chat.controller +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageReceiveDTO import busanVibe.busan.domain.chat.dto.websocket.ChatMessageResponseDTO import busanVibe.busan.domain.chat.dto.websocket.ChatMessageSendDTO import busanVibe.busan.domain.chat.service.ChatMongoService @@ -24,14 +25,18 @@ class ChatRestController( @PostMapping("/send") @Operation(summary = "채팅 전송 API", description = """ - 채팅 전송 API 입니다. 해당 API 호출 시, 메시지가 전송되고, 채팅 웹소켓에 연결된 유저들에게 메시지가 전송됩니다. - 메시지의 길이는 최대 200글자입니다. - type 유형 : ['CHAT', 'BOT'] - - CHAT (구현O): 일반 채팅 - - BOT (구현X): 챗봇 기능입니다. 본인에게만 웹소켓 메시지가 전송되고, 채팅방을 나갈 시 다시 볼 수 없습니다. + 채팅 전송 API 입니다. 해당 API 호출 시 메시지가 전송되고, 채팅 웹소켓에 연결된 유저들에게 메시지가 전송됩니다. + 메시지의 길이는 최대 200글자입니다. + + type : ['CHAT', 'BOT'] + + 메시지가 '/' 로 시작하면 챗봇 답변을 응답합니다. + 일반 채팅은 웹소켓으로 메시지를 전송하고, 챗봇은 웹소켓 메시지를 전송하지 않습니다. + 따라서 일반 채팅은 웹소켓으로 받은 메시지로 활용하고, 챗봇에게 받은 답변은 해당 API의 응답 결과를 활용해주세요. """) - fun sendMessage(@Valid @RequestBody chatMessage: ChatMessageSendDTO) { - chatMongoService.saveAndPublish(chatMessage) + fun sendMessage(@Valid @RequestBody chatMessage: ChatMessageSendDTO): ApiResponse { + val receiveDTO = chatMongoService.saveAndPublish(chatMessage) + return ApiResponse.onSuccess(receiveDTO) } @GetMapping("/history") diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/ChatRequest.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/ChatRequest.kt deleted file mode 100644 index a086ea0..0000000 --- a/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/ChatRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package busanVibe.busan.domain.chat.dto.openai - -class ChatRequest( - val model: String, - val messages: List, - val n: Int, - val max_tokens: Int -) { - constructor(model: String, prompt: String) : this( - model = model, - messages = listOf(ChatMessageDTO("user", prompt)), - n = 1, - max_tokens = 100 - ) -} diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/OpenAiDTO.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/OpenAiDTO.kt new file mode 100644 index 0000000..3d500d2 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/OpenAiDTO.kt @@ -0,0 +1,40 @@ +package busanVibe.busan.domain.chat.dto.openai + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class WebSearchRequest( + val model: String, + val messages: List, +// val tools: List? = null, +) + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class Message( + val role: String, + val content: String +) + +data class Tool( + val type: String +) + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class ApproximateLocation( + val country: String, + val city: String, + val region: String +) + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class WebSearchResponse( + val id: String, + val choices: List +) + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class Choice( + val index: Int, + val message: Message +) diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt index ee4ad4f..0729522 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt @@ -1,6 +1,5 @@ package busanVibe.busan.domain.chat.dto.websocket -import busanVibe.busan.domain.chat.enums.MessageType import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.databind.annotation.JsonNaming import jakarta.validation.constraints.NotBlank @@ -8,12 +7,11 @@ import jakarta.validation.constraints.Size @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) data class ChatMessageSendDTO( -// var userId: Long? = null, - var type: MessageType? = null, // 메시지 타입 + @field:NotBlank(message = "빈 문자열을 전송할 수 없습니다.") @field:Size(min = 1, max = 200, message = "메세지의 길이는 1이상 200이하입니다.") - var message: String? = null, // 메시지 -// var time: LocalDateTime? = null, // 전송 시간 + var message: String, // 메시지 + ){ } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatGPTService.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatGPTService.kt deleted file mode 100644 index b34e014..0000000 --- a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatGPTService.kt +++ /dev/null @@ -1,43 +0,0 @@ -package busanVibe.busan.domain.chat.service - -import busanVibe.busan.domain.chat.dto.openai.ChatRequest -import busanVibe.busan.domain.chat.dto.openai.ChatResponse -import busanVibe.busan.global.config.openai.ChatGPTConfig -import org.springframework.beans.factory.annotation.Value -import org.springframework.http.HttpEntity -import org.springframework.stereotype.Service -import org.springframework.web.client.RestTemplate - -@Service -class ChatGPTService( - private val chatGPTConfig: ChatGPTConfig, - - @Value("\${openai.model}") - private val model: String, - - @Value("\${openai.secret-key}") - private val url: String - -) { - - fun prompt(prompt: String): String { - - // 토큰 정보가 포함된 header 가져오기 - val headers = chatGPTConfig.httpHeaders() - - // create request - val chatRequest = ChatRequest(model, prompt) - - // 통신을 위한 RestTemplate 구성 - val requestEntity = HttpEntity(chatRequest, headers) - - val restTemplate = RestTemplate() - val response: ChatResponse? = restTemplate.postForObject(url, requestEntity, ChatResponse::class.java) - - if (response?.choices.isNullOrEmpty()) { - throw RuntimeException("No choices returned from ChatGPT.") - } - - return response!!.choices[0].message.content - } -} diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt index ee8b171..644dcf7 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt @@ -24,37 +24,86 @@ class ChatMongoService( private val redisPublisher: RedisPublisher, private val topic: ChannelTopic, private val userRepository: UserRepository, + private val openAiService: OpenAiService ) { val log: Logger = LoggerFactory.getLogger(ChatMongoService::class.java) - fun saveAndPublish(chatMessage: ChatMessageSendDTO) { - - // 현재 유저 조회 + /** + * + * 일반 채팅의 경우 + * 메시지 전송 - 메시지를 DB에 저장 - dto 생성 - dto를 웹소켓 전송 - 클라이언트 갱신 - dto를 반환(응답) + * + * 챗봇의 경우 + * 메시지 전송 - openai 요청 후 메시지 받음 - dto 생성 - dto를 반환(응답) + * + */ + fun saveAndPublish(chatMessage: ChatMessageSendDTO): ChatMessageReceiveDTO { + + // 필요한 정보 미리 정의 val currentUser = AuthService().getCurrentUser() + val now = LocalDateTime.now() + val message = chatMessage.message.trim() + + // 받은 메세지로 타입 구분 + // '/'로 시작하면 챗봇(BOT) + val type: MessageType = if(message[0] == '/'){ + MessageType.BOT + }else{ + MessageType.CHAT + } + + // 채팅 로그 생성 + log.info("[$type] userId: ${currentUser.id}, /POST/chat/send, 메시지 전송: $chatMessage") - // 채팅 객체 생성 - val document = ChatMessage( - type = chatMessage.type?: MessageType.CHAT, + // ChatMessage 객체 생성 + val chat = ChatMessage( + type = type, userId = currentUser.id, - message = chatMessage.message?:"", - time = LocalDateTime.now(), + message = message, + time = now ) - // 채팅 저장 - chatMongoRepository.save(document) + // 일반 채팅일 경우 채팅 기록 저장 + chatMongoRepository.save(chat) - val receiveDto = ChatMessageReceiveDTO( - name = currentUser.nickname, - imageUrl = currentUser.profileImageUrl, - message = document.message, - time = document.time, - type = document.type, - userId = currentUser.id - ) + // 유저들에게 웹소켓으로 전달할 메시지의 DTO 생성 + val receiveDto = buildReceiveDto(type, currentUser, chat, now) + + // 일반 채팅일 경우에만 유저들에게 웹소켓 메시지 보냄 + if(type == MessageType.CHAT) { + redisPublisher.publish(topic, receiveDto) + } + return receiveDto + } - log.info("[CHAT] /POST/chat/send, 메시지 전송: $chatMessage") - redisPublisher.publish(topic, receiveDto) + // ChatMessageReceiveDTO 생성 + private fun buildReceiveDto( + type: MessageType, + currentUser: User, + chat: ChatMessage, + timestamp: LocalDateTime + ): ChatMessageReceiveDTO { + return if (type == MessageType.CHAT) { + ChatMessageReceiveDTO( + name = currentUser.nickname, + imageUrl = currentUser.profileImageUrl, + message = chat.message, + time = timestamp, + type = type, + userId = currentUser.id + ) + } else { + val message = openAiService.chatToOpenAI(chat.message) + ChatMessageReceiveDTO( + name = "챗봇", + imageUrl = null, + message = message, + time = timestamp, + type = type, + userId = -1 + ) + } } fun getChatHistory(cursorId: String?, pageSize: Int = 15): ChatMessageResponseDTO.ListDto { diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/OpenAiService.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/OpenAiService.kt new file mode 100644 index 0000000..82bc8f0 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/OpenAiService.kt @@ -0,0 +1,174 @@ +package busanVibe.busan.domain.chat.service + +import busanVibe.busan.domain.chat.dto.openai.Message +import busanVibe.busan.domain.chat.dto.openai.WebSearchRequest +import busanVibe.busan.domain.chat.dto.openai.WebSearchResponse +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationContext +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient + +@Service +class OpenAiService( + private val webClient: WebClient, + private val context: ApplicationContext, + private val redisTemplate: RedisTemplate, + + @Value("\${openai.model}") + val model: String + +) { + + private val promptCacheKey = "systemPrompt:busanAI" + private val log = LoggerFactory.getLogger(OpenAiService::class.java) + + // openAI 채팅 요청 + fun chatToOpenAI(query: String): String{ + // 자기 자신 프록시 + val self = context.getBean(OpenAiService::class.java) + // 프롬프트 캐시 조회 + val systemPrompt = self.getLatestSystemPrompt() + // 캐시 적용하여 message 생성 + val messages = listOf( + systemPrompt, + Message("user", query) + ) + + // openai에 요청할 요청 body 생성 + val request = WebSearchRequest( + model = model, + messages = messages + ) + + // 요청하여 WebSearchResponse 형태로 받음 + val webSearchResponse = webClient.post() + .uri("/chat/completions") + .bodyValue(request) + .retrieve() + .bodyToMono(WebSearchResponse::class.java) + .block() + + // WebSearchResponse 에서 답변 추출 + val answer = webSearchResponse?.choices?.firstOrNull()?.message?.content ?: "답변 없음" + log.info("[답변]: {}", answer) + + // 반환 + return answer + } + + // 프롬프트 캐싱에 사용되는 메서드 + // Redis에서 프롬프트 조회 후, 현재 promptMessage와 다르면 갱신 + private fun getLatestSystemPrompt(): Message { + val cachedPrompt = redisTemplate.opsForValue().get(promptCacheKey) + return if (cachedPrompt == null || cachedPrompt != promptMessage) { + log.info("챗봇 프롬프트 메시지 변경") + redisTemplate.opsForValue().set(promptCacheKey, promptMessage) + Message("system", promptMessage) + } else { + Message("system", cachedPrompt) + } + } + + // 프롬프팅 메시지 + private val promptMessage:String = + """ + 부산의 명소(관광지, 식당, 카페, 문화시설, 의료시설 등)와 축제에 대해 안내하는 친근한 가이드 역할을 수행하세요. 반드시 아래 단계별 판단 및 답변 규칙을 준수하며, 답변은 모두 한글 500자 이내로 작성합니다. + + 답변 전 반드시 사용자의 질문을 아래 기준에 따라 분류·판단하고 그 판단에 따라 답변 내용을 결정하세요. 모든 응답은 부산 주민 또는 관광객을 위한 것임을 명심하며, 항상 따뜻하고 친근한 분위기와 권유, 추천, 마무리 인사를 포함하세요. + + # 단계별 답변 절차 + + 1. **질문 분류 및 주제 판단** + - 먼저 질문이 부산의 명소(관광지, 식당, 카페, 문화시설, 의료기관 등) 또는 축제와 직접적으로 관련된 내용인지 반드시 판별하세요. + - 다음 두 경우에 해당하지 않는지도 반드시 확인하세요. + - (a) 부산 외의 지역과 관련된 내용 + - (b) 부산지역이지만 명소/축제와 직접 관련 없는 정보(정치, 선정성, 개인신상, 일반 시사 등) + + 2. **예외/불가 주제 응답 처리** + - 질문이 (a) 또는 (b)에 해당하면 반드시 “부산톡은 부산 지역의 명소/축제에 관한 정보를 제공합니다.”라는 동일 안내 문구로만 답변하세요. + - 정치, 선정적, 개인정보 등 예민한 내용에는 위 안내 문구로 엄격하게 일관 처리하세요. + + 3. **일상 대화 및 정보성 약한 질문 응대** + - 인사(“안녕”, “ㅎㅇ”, “반가워”, “넌 누구니” 등), 자기소개 요청, “요즘 날씨 어때?” 등 정보 제공이 아닌 단순 대화일 경우, 친근한 톤으로 짧게 반갑고 안내/유도성 문구로 답변하세요. + - 단, 정치·선정적 내용이 결합된 경우 “부산톡은 해당 분야를 답변하지 않습니다.”로 답하세요. + - “부산 날씨 어때?”, “부산 어디에 있어?”, “부산 인구는 몇 명이야?” 등 명소/축제와 직접적 연관은 없으나 간접적으로 연결될 수 있는 질문에는 친근하게 짧은 정보 제공이 허용됩니다. + + 4. **명소/축제 안내 답변** + - 질문이 명소(관광지, 식당, 카페, 문화·의료시설 등)나 축제와 직접적으로 관련된 경우, 따뜻하고 유도/추천이 담긴 아래와 같은 구조, 어투를 반드시 따르세요. + - (1) 해당 장소/축제를 부산 대표 명소임을 포함해 간단히 소개 + - (2) 장소/축제의 매력과 특징, 추천 이유를 친근하게 설명 + - (3) 마지막에 자연스러운 권유·마무리 인사 포함(예: “한번 가보면 어떨까?”, “꼭 방문해봐!” 등) + - 글 전체는 친근·따뜻한 대화체(예시 참고), 명확한 정보, 권유, 추천, 마무리로 마무리하세요. + + # 출력 형식 + + - 모든 답변은 한글만 사용, 반드시 500자 이내의 한 문장 또는 짧은 단락으로 작성해. + - 명소/축제와 무관한 내용(다른 지역, 정치, 선정성, 신상 등)은 반드시 "부산톡은 부산 지역의 명소/축제에 관한 정보를 제공합니다."로만 응답 + - 일상 대화/간단 안부/가벼운 자기소개 등은 친근한 표준 안내 대화체로 응답 + - 질문 분류→해당 유형별 출력 규칙→톤·길이·형식 유지 순으로 출력 + + # 예시 + + --- + + **예시 1** + - 질문: 부산에서 재미있는 축제 알려줘. + - 답변: 부산에선 여름마다 열리는 ‘부산바다축제’가 정말 인기야! 해변 음악 공연부터 다양한 체험까지 가득해서 신나는 시간이 될 거야. 한 번 가보면 어떨까? + + --- + + **예시 2** + - 질문: 해운대는 어떤 곳이야? + - 답변: 해운대에 대해 알려줄게! 해운대는 부산을 대표하는 해수욕장으로, 넓은 백사장과 맑은 바다가 펼쳐져 있어서 많은 사람들이 사랑하는 곳이야. 가족이나 친구와 함께 방문해 보는 건 어때? + + --- + + **예시 3** + - 질문: 서울에 가볼 만한 곳 있어? + - 답변: 부산톡은 부산 지역의 명소/축제에 관한 정보를 제공합니다. + + --- + + **예시 4** + - 질문: 안녕! + - 답변: 안녕하세요! 부산 여행 준비 중이신가요? 궁금한 부산 명소나 축제 있으면 언제든 편하게 물어보세요. + + --- + + **예시 5** + - 질문: 부산 시장에 대해 어떻게 생각해? + - 답변: 부산 시장은 여러 군데 있어서 상황에 맞게 선택할 수 있고 다양한 먹거리를 즐길 수 있어. + + --- + + **예시 6** + - 질문: 너는 누구야? + - 답변: 반가워! 나는 부산의 명소와 축제에 대해 안내해주는 부산톡이야. 궁금한 곳이 있으면 언제든 물어봐줘~ + + --- + + **예시 7** + - 질문: 요즘 부산 날씨 어때? + - 답변: 최근 부산 날씨는 약 21도로 산책하기 좋을 날씨에요!. + + --- + + # 참고 사항 + + - 항상 “질문 주제 분류→판단→답변 생성”의 순차적 판단과 출력을 엄수하세요. + - 예외·불가(지역 외, 정보 무관, 정치/선정성 등)는 지정된 표준 안내문으로만 답변 + - 명소/축제 안내는 밝고 따뜻한 분위기, 추천, 마무리 인사 포함 + - 한글 500자 이내 유지, 예시와 같은 어투·문장 길이를 준수 + - 가독성을 위해 줄바꿈을 적절히 활용하세요. + - 실제 답변 작성 시, 예시에서와 같이 분류·판단→유형별 답변→음성 톤 및 출력 규칙을 항상 지켜야 합니다. + + # Reminder + + 질문을 반드시 먼저 분류·판단한 뒤, 결정된 유형과 예시를 바탕으로 따뜻하고 권유·추천을 더한 500자 이내의 한글 답변을 출력하세요. 항상 분류/판단체계를 선행하며, 각 상황별 안내문·톤·글길이·형식을 지키는지 점검하세요. + 줄바꿈이 적절히 이루어졌는지 점검하세요. + + """.trimIndent() + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisSubscriber.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisSubscriber.kt index 4472de4..eb36fc1 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisSubscriber.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisSubscriber.kt @@ -17,10 +17,10 @@ class RedisSubscriber( private val log = LoggerFactory.getLogger(RedisSubscriber::class.java) override fun onMessage(message: Message, pattern: ByteArray?) { - val chatMessage = serializer.deserialize(message.body, ChatMessageReceiveDTO::class.java) as? ChatMessageReceiveDTO + val chatMessage = serializer.deserialize(message.body, ChatMessageReceiveDTO::class.java) if (chatMessage != null) { - log.info("🔔 수신된 메시지: {}", chatMessage) messagingTemplate.convertAndSend("/sub/chatroom", chatMessage) + log.info("🔔 수신된 메시지: {}", chatMessage) } else { log.warn("❌ 수신된 메시지를 ChatMessageDTO로 변환할 수 없습니다.") } diff --git a/src/main/kotlin/busanVibe/busan/global/config/WebClientConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/WebClientConfig.kt index 0d4e97a..b9ed0b8 100644 --- a/src/main/kotlin/busanVibe/busan/global/config/WebClientConfig.kt +++ b/src/main/kotlin/busanVibe/busan/global/config/WebClientConfig.kt @@ -1,12 +1,16 @@ package busanVibe.busan.global.config +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpHeaders import org.springframework.web.reactive.function.client.WebClient @Configuration -class WebClientConfig { +class WebClientConfig( + @Value("\${openai.secret-key}") + val apiKey: String +) { @Bean fun kakaoTokenClient(): WebClient{ @@ -23,4 +27,14 @@ class WebClientConfig { .baseUrl("https://kapi.kakao.com") .build(); } + + @Bean + fun webClient(): WebClient { + return WebClient.builder() + .baseUrl("https://api.openai.com/v1") + .defaultHeader("Authorization", "Bearer $apiKey") + .defaultHeader("Content-Type", "application/json") + .build() + } + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/webSocket/AuthChannelInterceptorAdapter.kt b/src/main/kotlin/busanVibe/busan/global/config/webSocket/AuthChannelInterceptorAdapter.kt index f85ae41..1aff033 100644 --- a/src/main/kotlin/busanVibe/busan/global/config/webSocket/AuthChannelInterceptorAdapter.kt +++ b/src/main/kotlin/busanVibe/busan/global/config/webSocket/AuthChannelInterceptorAdapter.kt @@ -2,6 +2,8 @@ package busanVibe.busan.global.config.webSocket import busanVibe.busan.domain.user.repository.UserRepository import busanVibe.busan.global.config.security.JwtTokenProvider +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.springframework.messaging.Message import org.springframework.messaging.MessageChannel import org.springframework.messaging.simp.stomp.StompCommand @@ -16,9 +18,10 @@ class AuthChannelInterceptorAdapter( private val userRepository: UserRepository ) : ChannelInterceptor { + private val log: Logger = LoggerFactory.getLogger(AuthChannelInterceptorAdapter::class.java) + override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? { val accessor = StompHeaderAccessor.wrap(message) - if (StompCommand.CONNECT == accessor.command) { val authHeader = accessor.getFirstNativeHeader("Authorization") if (authHeader == null || !authHeader.startsWith("Bearer ")) { @@ -35,7 +38,7 @@ class AuthChannelInterceptorAdapter( IllegalArgumentException("User not found") } - println("웹소켓 연결 유저: id=${user.id}, email=${user.email}") + log.info("[Web Socket] 웹소켓 연결 유저: id=${user.id}, email=${user.email}") val auth = jwtTokenProvider.getAuthentication(token) accessor.user = auth diff --git a/src/main/kotlin/busanVibe/busan/global/config/webSocket/JwtHandshakeInterceptor.kt b/src/main/kotlin/busanVibe/busan/global/config/webSocket/JwtHandshakeInterceptor.kt deleted file mode 100644 index cb98980..0000000 --- a/src/main/kotlin/busanVibe/busan/global/config/webSocket/JwtHandshakeInterceptor.kt +++ /dev/null @@ -1,51 +0,0 @@ -package busanVibe.busan.global.config.webSocket - -import busanVibe.busan.domain.user.repository.UserRepository -import busanVibe.busan.global.apiPayload.code.status.ErrorStatus -import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler -import busanVibe.busan.global.config.security.JwtTokenProvider -import org.springframework.http.server.ServerHttpRequest -import org.springframework.http.server.ServerHttpResponse -import org.springframework.http.server.ServletServerHttpRequest -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.web.socket.WebSocketHandler -import org.springframework.web.socket.server.HandshakeInterceptor - -class JwtHandshakeInterceptor( - private val jwtTokenProvider: JwtTokenProvider, - private val userRepository: UserRepository -) : HandshakeInterceptor { - - override fun beforeHandshake( - request: ServerHttpRequest, response: ServerHttpResponse, - wsHandler: WebSocketHandler, attributes: MutableMap - ): Boolean { - val servletRequest = (request as? ServletServerHttpRequest)?.servletRequest - val authHeader = servletRequest?.getHeader("Authorization") ?: return false - if (!authHeader.startsWith("Bearer ")) return false - val token = authHeader.substring(7) - - if (!jwtTokenProvider.validateToken(token)) { - return false - } - - val userEmail = jwtTokenProvider.getIdFromToken(token) - val user = userRepository.findByEmail(userEmail) - .orElseThrow { ExceptionHandler(ErrorStatus.USER_NOT_FOUND) } - - // Spring Security 인증 객체 생성 - val auth = UsernamePasswordAuthenticationToken(user, null, user.authorities) - - // WebSocket session attributes에 Principal로 넣어줌 - attributes["principal"] = auth - - return true - } - - override fun afterHandshake( - request: ServerHttpRequest, response: ServerHttpResponse, - wsHandler: WebSocketHandler, exception: Exception? - ) { - // ... - } -} diff --git a/src/main/kotlin/busanVibe/busan/global/config/webSocket/WebSocketConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/webSocket/WebSocketConfig.kt index 05c48b0..c1cd625 100644 --- a/src/main/kotlin/busanVibe/busan/global/config/webSocket/WebSocketConfig.kt +++ b/src/main/kotlin/busanVibe/busan/global/config/webSocket/WebSocketConfig.kt @@ -24,9 +24,10 @@ class WebSocketConfig( "http://localhost:*", "http://127.0.0.1:*", "https://busanvibe.site", - "https://*.busanvibe.site" + "https://*.busanvibe.site", + "ws://localhost:*", + "ws://127.0.0.1:*", ) -// .addInterceptors(JwtHandshakeInterceptor(jwtTokenProvider, userRepository)) .withSockJS() .setHeartbeatTime(10000) } From ca692c05893ad23fabdd502f610cbc510f50e2bc Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 21 Aug 2025 22:03:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=9D=BC=EB=B0=98=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=A7=8C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../busan/domain/chat/repository/ChatMongoRepository.kt | 4 ++++ .../busanVibe/busan/domain/chat/service/ChatMongoService.kt | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/repository/ChatMongoRepository.kt b/src/main/kotlin/busanVibe/busan/domain/chat/repository/ChatMongoRepository.kt index 64f2b7e..fbd16fe 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/repository/ChatMongoRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/repository/ChatMongoRepository.kt @@ -1,10 +1,14 @@ package busanVibe.busan.domain.chat.repository import busanVibe.busan.domain.chat.domain.ChatMessage +import busanVibe.busan.domain.chat.enums.MessageType import org.springframework.data.domain.Pageable import org.springframework.data.mongodb.repository.MongoRepository interface ChatMongoRepository: MongoRepository { fun findAllByOrderByTimeDesc(pageable: Pageable): List fun findByIdLessThanOrderByTimeDesc(id: String, pageable: Pageable): List + fun findAllByTypeOrderByTimeDesc(type: MessageType, pageable: Pageable): List + fun findByIdLessThanAndTypeOrderByTimeDesc(id: String, type: MessageType, pageable: Pageable): List + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt index 644dcf7..3fc152d 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt @@ -119,9 +119,9 @@ class ChatMongoService( // 조회 -> List 변수 선언 및 초기화 val chatHistory: List = if (cursorId != null) { // 처음이면 cursorId 없이 조회 - chatMongoRepository.findByIdLessThanOrderByTimeDesc(cursorId, Pageable.ofSize(pageSize)) + chatMongoRepository.findByIdLessThanAndTypeOrderByTimeDesc(cursorId, MessageType.CHAT, Pageable.ofSize(pageSize)) } else { // 처음 아니면 cursorId로 조회 - chatMongoRepository.findAllByOrderByTimeDesc(Pageable.ofSize(pageSize)) + chatMongoRepository.findAllByTypeOrderByTimeDesc(MessageType.CHAT, Pageable.ofSize(pageSize)) } // userId List 저장. 바로 뒤에 userId로 User 정보들을 먼저 찾고, 그 뒤에 DTO 변환