From 82a3bd7acb0474aa9d1a7e9c6af42e9dc557fd9e Mon Sep 17 00:00:00 2001 From: Mrbaeksang Date: Fri, 26 Sep 2025 14:23:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(be):=20AI=20=EC=B1=97=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20REST=20API=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiChatController 6개 엔드포인트 완성 * GET /sessions (세션 목록) * POST /sessions (세션 생성) * DELETE /sessions/{id} (세션 삭제) * GET /sessions/{id}/messages (메시지 조회) * POST /sessions/{id}/messages (메시지 전송) * PATCH /sessions/{id}/title (제목 수정) - Service Layer Entity 반환으로 리팩토링 - Controller Layer DTO 변환 책임 분리 - getSessionWithOwnershipCheck 헬퍼 메서드 적용 - ChatController → AiChatController 리네임 - DTO 구조 정리 및 신규 DTO 추가 --- docs/api-specification.yaml | 44 ++++ .../ai/aiChat/controller/AiChatController.kt | 85 ++++++++ .../ai/aiChat/controller/ChatController.kt | 200 ------------------ .../domain/ai/aiChat/dto/AiChatRequest.kt | 5 + .../domain/ai/aiChat/dto/AiChatResponse.kt | 6 + .../domain/ai/aiChat/dto/ChatRequest.kt | 6 - .../domain/ai/aiChat/dto/ChatResponse.kt | 7 - .../ai/aiChat/dto/DeleteAiChatRequest.kt | 5 + .../ai/aiChat/dto/SessionMessagesResponse.kt | 8 + .../domain/ai/aiChat/dto/SessionsResponse.kt | 6 + .../aiChat/dto/UpdateSessionTitleRequest.kt | 5 + .../aiChat/dto/UpdateSessionTitleResponse.kt | 5 + .../domain/ai/aiChat/entity/AiChatSession.kt | 4 +- .../repository/AiChatMessageRepository.kt | 4 + .../repository/AiChatSessionRepository.kt | 2 + .../domain/ai/aiChat/service/AiChatService.kt | 61 +++++- 16 files changed, 228 insertions(+), 225 deletions(-) create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/AiChatController.kt delete mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/ChatController.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatRequest.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/AiChatResponse.kt delete mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatRequest.kt delete mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/ChatResponse.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/DeleteAiChatRequest.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionMessagesResponse.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/SessionsResponse.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleRequest.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/dto/UpdateSessionTitleResponse.kt 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..c3389e1 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/AiChatController.kt @@ -0,0 +1,85 @@ +package com.back.koreaTravelGuide.domain.ai.aiChat.controller + +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.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, + ): List { + return aiChatService.getSessions(userId).map { + SessionsResponse(it.id!!, it.sessionTitle) + } + } + + @PostMapping("/sessions") + fun createSession( + @RequestParam userId: Long, + ): SessionsResponse { + val session = aiChatService.createSession(userId) + return SessionsResponse(session.id!!, session.sessionTitle) + } + + @DeleteMapping("/sessions/{sessionId}") + fun deleteSession( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + ) { + aiChatService.deleteSession(sessionId, userId) + } + + @GetMapping("/sessions/{sessionId}/messages") + fun getSessionMessages( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + ): List { + val messages = aiChatService.getSessionMessages(sessionId, userId) + return messages.map { + SessionMessagesResponse(it.content, it.senderType) + } + } + + @PostMapping("/sessions/{sessionId}/messages") + fun sendMessage( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + @RequestBody request: AiChatRequest, + ): AiChatResponse { + val (userMessage, aiMessage) = aiChatService.sendMessage(sessionId, userId, request.message) + return AiChatResponse( + userMessage = userMessage.content, + aiMessage = aiMessage.content, + ) + } + + @PatchMapping("/sessions/{sessionId}/title") + fun updateSessionTitle( + @PathVariable sessionId: Long, + @RequestParam userId: Long, + @RequestBody request: UpdateSessionTitleRequest, + ): UpdateSessionTitleResponse { + val updatedSession = aiChatService.updateSessionTitle(sessionId, userId, request.newTitle) + return UpdateSessionTitleResponse( + newTitle = updatedSession.sessionTitle, + ) + } +} 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 0562b86..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/controller/ChatController.kt +++ /dev/null @@ -1,200 +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.remove.WeatherResponse -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) location: String?, - @RequestParam(required = false) regionCode: String?, - @RequestParam(required = false) baseTime: String?, - ): WeatherResponse { - return weatherTool.getWeatherForecast( - location = location, - regionCode = regionCode, - 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("해당 채팅방이 없거나 접근 권한이 없습니다.") + } } From 253bd396c224e72a576af09a06e2787e3ae30efb Mon Sep 17 00:00:00 2001 From: Mrbaeksang Date: Fri, 26 Sep 2025 14:38:55 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EA=B8=80=EB=A1=9C=EB=B2=8C=20=EC=9D=B5?= =?UTF-8?q?=EC=85=89=EC=85=98=20=EC=84=A4=EC=A0=95=EC=97=90=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20=C3=AApr=EB=A6=AC=EB=B7=B0=20=C3=AB=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD=20+=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/aiChat/controller/AiChatController.kt | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) 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 index c3389e1..2af0e76 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -7,6 +8,7 @@ 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 @@ -25,37 +27,43 @@ class AiChatController( @GetMapping("/sessions") fun getSessions( @RequestParam userId: Long, - ): List { - return aiChatService.getSessions(userId).map { - SessionsResponse(it.id!!, it.sessionTitle) - } + ): ResponseEntity>> { + val sessions = + aiChatService.getSessions(userId).map { + SessionsResponse(it.id!!, it.sessionTitle) + } + return ResponseEntity.ok(ApiResponse("채팅방 목록을 성공적으로 조회했습니다.", sessions)) } @PostMapping("/sessions") fun createSession( @RequestParam userId: Long, - ): SessionsResponse { + ): ResponseEntity> { val session = aiChatService.createSession(userId) - return SessionsResponse(session.id!!, session.sessionTitle) + 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, - ): List { + ): ResponseEntity>> { val messages = aiChatService.getSessionMessages(sessionId, userId) - return messages.map { - SessionMessagesResponse(it.content, it.senderType) - } + val response = + messages.map { + SessionMessagesResponse(it.content, it.senderType) + } + return ResponseEntity.ok(ApiResponse("채팅 메시지를 성공적으로 조회했습니다.", response)) } @PostMapping("/sessions/{sessionId}/messages") @@ -63,12 +71,14 @@ class AiChatController( @PathVariable sessionId: Long, @RequestParam userId: Long, @RequestBody request: AiChatRequest, - ): AiChatResponse { + ): ResponseEntity> { val (userMessage, aiMessage) = aiChatService.sendMessage(sessionId, userId, request.message) - return AiChatResponse( - userMessage = userMessage.content, - aiMessage = aiMessage.content, - ) + val response = + AiChatResponse( + userMessage = userMessage.content, + aiMessage = aiMessage.content, + ) + return ResponseEntity.ok(ApiResponse("메시지가 성공적으로 전송되었습니다.", response)) } @PatchMapping("/sessions/{sessionId}/title") @@ -76,10 +86,12 @@ class AiChatController( @PathVariable sessionId: Long, @RequestParam userId: Long, @RequestBody request: UpdateSessionTitleRequest, - ): UpdateSessionTitleResponse { + ): ResponseEntity> { val updatedSession = aiChatService.updateSessionTitle(sessionId, userId, request.newTitle) - return UpdateSessionTitleResponse( - newTitle = updatedSession.sessionTitle, - ) + val response = + UpdateSessionTitleResponse( + newTitle = updatedSession.sessionTitle, + ) + return ResponseEntity.ok(ApiResponse("채팅방 제목이 성공적으로 수정되었습니다.", response)) } }