From 343e37f90dcb9cefe2a57c0957969aaa29d12cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=8F=99=ED=95=98?= Date: Sat, 27 Sep 2025 18:54:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(be):=EC=86=8C=EC=85=9C=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/CustomOAuth2UserService.kt | 29 +++++++++++++ .../common/security/SecurityConfig.kt | 2 +- src/main/resources/application.yml | 42 ++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt index 5a61b2c..a3739c5 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt @@ -23,6 +23,8 @@ class CustomOAuth2UserService( val oAuthUserInfo = when (provider) { "google" -> parseGoogle(attributes) + "naver" -> parseNaver(attributes) + "kakao" -> parseKakao(attributes) else -> throw IllegalArgumentException("지원하지 않는 소셜 로그인입니다.") } @@ -57,6 +59,33 @@ class CustomOAuth2UserService( profileImageUrl = attributes["picture"] as String?, ) } + + private fun parseNaver(attributes: Map): OAuthUserInfo { + val response = attributes["response"] as Map + + return OAuthUserInfo( + oauthId = response["id"] as String, + email = response["email"] as String, + nickname = response["name"] as String, + profileImageUrl = response["profile_image"] as String?, + ) + } + + private fun parseKakao(attributes: Map): OAuthUserInfo { + val kakaoAccount = attributes["kakao_account"] as? Map + val profile = kakaoAccount?.get("profile") as? Map + val kakaoId = attributes["id"].toString() + + // 카카오는 이메일 못받아서 이렇게 처리했음 + val email = kakaoAccount?.get("email") as? String ?: "kakao_$kakaoId@social.login" + + return OAuthUserInfo( + oauthId = kakaoId, + email = email, + nickname = profile?.get("nickname") as? String ?: "사용자", + profileImageUrl = profile?.get("profile_image_url") as? String, + ) + } } data class OAuthUserInfo( diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt index cb687f6..c854245 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt @@ -68,7 +68,7 @@ class SecurityConfig( } if (!isDev) { - addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + addFilterBefore(jwtAuthenticationFilter) } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 57aa50f..fdd641b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -70,13 +70,53 @@ spring: session: store-type: none # Redis 없어도 실행 가능하도록 변경 timeout: 30m - # Redis 자동 설정 비활성화 (세션 비활성화용) autoconfigure: exclude: - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + security: + oauth2: + client: + registration: + google: + client-id: 11962659605-optp0eb5h1c1ob1b9qie53s83j165qt8.apps.googleusercontent.com + client-secret: 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 + scope: + - profile + - email + naver: + client-id: At2KDxDK_ppHMuMYqEDL + client-secret: osKwSljl9i + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: + - name + - email + - profile_image + client-name: Naver + kakao: + client-id: da170def761db299a36665bbcf50817b + client-secret: SoEKP391vVXvrMy3nANjdWZA7RrheBJW + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: + - profile_nickname + - profile_image + client-name: Kakao + provider: + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id # Swagger API 문서 설정 (주니어 개발자용) springdoc: api-docs: From a16c757290a6c28bc692bba279d6b94df41e13df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=8F=99=ED=95=98?= Date: Sun, 28 Sep 2025 20:28:55 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=82=A4=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 +++++++++- src/main/resources/application.yml | 12 ++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 1ca4197..18efef2 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,12 @@ REDIS_PASSWORD= # DB_PASSWORD=your-db-password # 🔧 개발 모드 설정 -SPRING_PROFILES_ACTIVE=dev \ No newline at end of file +SPRING_PROFILES_ACTIVE=dev + +# 🔐 OAuth 2.0 Client Credentials +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +NAVER_CLIENT_ID=your-naver-client-id +NAVER_CLIENT_SECRET=your-naver-client-secret +KAKAO_CLIENT_ID=your-kakao-client-id +KAKAO_CLIENT_SECRET=your-kakao-client-secret \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fdd641b..5720bf1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -81,14 +81,14 @@ spring: client: registration: google: - client-id: 11962659605-optp0eb5h1c1ob1b9qie53s83j165qt8.apps.googleusercontent.com - client-secret: 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 + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} scope: - profile - email naver: - client-id: At2KDxDK_ppHMuMYqEDL - client-secret: osKwSljl9i + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: @@ -97,8 +97,8 @@ spring: - profile_image client-name: Naver kakao: - client-id: da170def761db299a36665bbcf50817b - client-secret: SoEKP391vVXvrMy3nANjdWZA7RrheBJW + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} client-authentication-method: client_secret_post authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" From 3f670c8e5f9116d4bdaeba64376b7a674174d48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BBE8=5D=EC=9D=B4=EC=A4=80=EB=AA=A8?= Date: Sun, 28 Sep 2025 19:59:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(be):=20userChat=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9E=84=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(be): userChat service,controller,SSE 추가 * feat(be) : controller,service,repository 팀 스타일에 맞게 수정 --- .../domain/userChat/UserChatSseEvents.kt | 34 ++++++++ .../chatmessage/entity/ChatMessage.kt | 2 +- .../repository/ChatMessageRepository.kt | 2 + .../chatmessage/service/ChatMessageService.kt | 42 +++++++++ .../chatroom/controller/ChatRoomController.kt | 85 +++++++++++++++++++ .../userChat/chatroom/entity/ChatRoom.kt | 6 +- .../chatroom/repository/ChatRoomRepository.kt | 16 +++- .../chatroom/service/ChatRoomService.kt | 44 ++++++++++ 8 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt new file mode 100644 index 0000000..4fb9ac3 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt @@ -0,0 +1,34 @@ +package com.back.koreaTravelGuide.domain.userChat + +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.util.concurrent.ConcurrentHashMap + +// Websocket,Stomp 사용 전 임시로 만들었음 +// 테스트 후 제거 예정 + +@Component +class UserChatSseEvents { + private val emitters = ConcurrentHashMap>() + + fun subscribe(roomId: Long): SseEmitter { + val emitter = SseEmitter(0L) + emitters.computeIfAbsent(roomId) { mutableListOf() }.add(emitter) + emitter.onCompletion { emitters[roomId]?.remove(emitter) } + emitter.onTimeout { emitter.complete() } + return emitter + } + + fun publishNew( + roomId: Long, + lastMessageId: Long, + ) { + emitters[roomId]?.toList()?.forEach { + try { + it.send(SseEmitter.event().name("NEW").data(lastMessageId)) + } catch (_: Exception) { + it.complete() + } + } + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt index ccd0e28..991554a 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt @@ -21,7 +21,7 @@ data class ChatMessage( val roomId: Long, @Column(name = "sender_id", nullable = false) val senderId: Long, - @Column(columnDefinition = "text", nullable = false) + @Column(nullable = false, columnDefinition = "text") val content: String, @Column(name = "created_at", nullable = false) val createdAt: Instant = Instant.now(), diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt index 651ed17..b32d0b1 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt @@ -12,4 +12,6 @@ interface ChatMessageRepository : JpaRepository { roomId: Long, afterId: Long, ): List + + fun deleteByRoomId(roomId: Long) } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt new file mode 100644 index 0000000..26f73a0 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt @@ -0,0 +1,42 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.service + +import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage +import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository +import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChatMessageService( + private val msgRepository: ChatMessageRepository, + private val roomRepository: ChatRoomRepository, +) { + data class SendMessageReq(val senderId: Long, val content: String) + + fun getlistbefore( + roomId: Long, + limit: Int, + ): List = msgRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed() + + fun getlistafter( + roomId: Long, + afterId: Long, + ): List = msgRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId) + + @Transactional + fun deleteByRoom(roomId: Long) { + msgRepository.deleteByRoomId(roomId) + } + + @Transactional + fun send( + roomId: Long, + req: SendMessageReq, + ): ChatMessage { + val saved = msgRepository.save(ChatMessage(roomId = roomId, senderId = req.senderId, content = req.content)) + roomRepository.findById(roomId).ifPresent { + roomRepository.save(it.copy(updatedAt = saved.createdAt, lastMessageId = saved.id)) + } + return saved + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt new file mode 100644 index 0000000..432b365 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt @@ -0,0 +1,85 @@ +package com.back.koreaTravelGuide.domain.userChat.chatroom.controller + +import com.back.koreaTravelGuide.common.ApiResponse +import com.back.koreaTravelGuide.domain.userChat.UserChatSseEvents +import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService +import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService +import org.springframework.http.MediaType +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.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 +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +// 컨트롤러는 임시로 강사님 스타일 따라서 통합해놓았음. 추후 리팩토링 예정 +@RestController +@RequestMapping("/api/userchat/rooms") +class ChatRoomController( + private val roomSvc: ChatRoomService, + private val msgSvc: ChatMessageService, + private val events: UserChatSseEvents, +) { + data class StartChatReq(val guideId: Long, val userId: Long) + + data class DeleteChatReq(val userId: Long) + + // MVP: 같은 페어는 방 재사용 + @PostMapping("/start") + fun startChat( + @RequestBody req: StartChatReq, + ): ResponseEntity>> { + val roomId = roomSvc.exceptOneToOneRoom(req.guideId, req.userId).id!! + return ResponseEntity.ok(ApiResponse(msg = "채팅방 시작", data = mapOf("roomId" to roomId))) + } + + @DeleteMapping("/{roomId}") + fun deleteRoom( + @PathVariable roomId: Long, + @RequestBody req: DeleteChatReq, + ): ResponseEntity> { + roomSvc.deleteByOwner(roomId, req.userId) + return ResponseEntity.ok(ApiResponse("채팅방 삭제 완료")) + } + + @GetMapping("/{roomId}") + fun get( + @PathVariable roomId: Long, + ) = ResponseEntity.ok(ApiResponse(msg = "채팅방 조회", data = roomSvc.get(roomId))) + + @GetMapping("/{roomId}/messages") + fun listMessages( + @PathVariable roomId: Long, + @RequestParam(required = false) after: Long?, + @RequestParam(defaultValue = "50") limit: Int, + ): ResponseEntity> { + val messages = + if (after == null) { + msgSvc.getlistbefore(roomId, limit) + } else { + msgSvc.getlistafter(roomId, after) + } + return ResponseEntity.ok(ApiResponse(msg = "메시지 조회", data = messages)) + } + + @PostMapping("/{roomId}/messages") + fun sendMessage( + @PathVariable roomId: Long, + @RequestBody req: ChatMessageService.SendMessageReq, + ): ResponseEntity> { + val saved = msgSvc.send(roomId, req) + events.publishNew(roomId, saved.id!!) + return ResponseEntity.status(201).body(ApiResponse(msg = "메시지 전송", data = saved)) + } + + // SSE는 스트림이여서 ApiResponse로 감싸지 않았음 + // WebSocket,Stomp 적용되면 바로 삭제 예정 + @GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + fun subscribe( + @PathVariable roomId: Long, + ): SseEmitter = events.subscribe(roomId) +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt index 9a581c5..09fa508 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt @@ -19,8 +19,10 @@ data class ChatRoom( val id: Long? = null, @Column(nullable = false) val title: String, - @Column(name = "owner_id", nullable = false) - val ownerId: Long, + @Column(name = "guide_id", nullable = false) + val guideId: Long, + @Column(name = "user_id", nullable = false) + val userId: Long, @Column(name = "updated_at", nullable = false) val updatedAt: Instant = Instant.now(), @Column(name = "last_message_id") diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt index 57287e7..1174fcd 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt @@ -2,7 +2,21 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.repository import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface ChatRoomRepository : JpaRepository +interface ChatRoomRepository : JpaRepository { + // 가이드,유저 방 생성시 중복 생성 방지 + @Query( + """ + select r from ChatRoom r + where (r.guideId = :guideId and r.userId = :userId) + or (r.guideId = :userId and r.userId = :guideId) + """, + ) + fun findOneToOneRoom( + guideId: Long, + userId: Long, + ): ChatRoom? +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt new file mode 100644 index 0000000..75a0965 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt @@ -0,0 +1,44 @@ +package com.back.koreaTravelGuide.domain.userChat.chatroom.service + +import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom +import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +@Service +class ChatRoomService( + private val roomRepository: ChatRoomRepository, +) { + data class CreateRoomReq(val title: String, val guideId: Long, val userId: Long) + + @Transactional + fun exceptOneToOneRoom( + guideId: Long, + userId: Long, + ): ChatRoom { + // 1) 기존 방 재사용 + roomRepository.findOneToOneRoom(guideId, userId)?.let { return it } + + // 2) 없으면 생성 (동시요청은 DB 유니크 인덱스로 가드) + val title = "Guide-$guideId · User-$userId" + return roomRepository.save( + ChatRoom(title = title, guideId = guideId, userId = userId, updatedAt = Instant.now()), + ) + } + + fun get(roomId: Long): ChatRoom = roomRepository.findById(roomId).orElseThrow { NoSuchElementException("room not found: $roomId") } + + @Transactional + fun deleteByOwner( + roomId: Long, + requesterId: Long, + ) { + val room = get(roomId) + if (room.userId != requesterId) { + // 예외처리 임시 + throw IllegalArgumentException("채팅방 생성자만 삭제할 수 있습니다.") + } + roomRepository.deleteById(roomId) + } +} From 6c5d3468526c8b6c8ae06e99da3c61914c50face Mon Sep 17 00:00:00 2001 From: YangHyeonJoon Date: Sun, 28 Sep 2025 20:13:53 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(be)=20:=20Tour=20Service=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84,=20Tour=20Client,=20Dto=20=EA=B0=9C=EC=84=A0=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(be): DisplayName 어노테이션 Test에 반영 * fix(be): TourApiClient 수정, List형태로 결과를 반환 * feat(be): Tour service 구현 및 Dto구조 변경 (#24) * fix(be) : TourResponse dto 구조 변경 * feat(be): ai codeReview 반영,ã…기본 값 null로 변경 * feat(be) TourResponse Dto 구조 변경에 따른 Client, Service 리팩토링 --- .../domain/ai/tour/client/TourApiClient.kt | 114 ++++++++++++------ .../domain/ai/tour/dto/TourResponse.kt | 20 +-- .../{InternalData.kt => TourSearchParams.kt} | 25 ++-- .../domain/ai/tour/service/TourService.kt | 50 +++++++- .../ai/tour/client/TourApiClientTest.kt | 8 +- 5 files changed, 153 insertions(+), 64 deletions(-) rename src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/{InternalData.kt => TourSearchParams.kt} (50%) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt index 678af78..a965e0b 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt @@ -1,15 +1,17 @@ package com.back.koreaTravelGuide.domain.ai.tour.client -import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.client.RestTemplate import org.springframework.web.util.UriComponentsBuilder import java.net.URI -// 09.25 양현준 +// 09.26 양현준 @Component class TourApiClient( private val restTemplate: RestTemplate, @@ -17,8 +19,11 @@ class TourApiClient( @Value("\${tour.api.key}") private val serviceKey: String, @Value("\${tour.api.base-url}") private val apiUrl: String, ) { + // println 대신 SLF4J 로거 사용 + private val logger = LoggerFactory.getLogger(TourApiClient::class.java) + // 요청 URL 구성 - private fun buildUrl(params: InternalData): URI = + private fun buildUrl(params: TourSearchParams): URI = UriComponentsBuilder.fromUri(URI.create(apiUrl)) .path("/areaBasedList2") .queryParam("serviceKey", serviceKey) @@ -35,49 +40,78 @@ class TourApiClient( .toUri() // 지역 기반 관광 정보 조회 (areaBasedList2) - fun fetchTourInfo(params: InternalData): TourResponse? { - println("URL 생성") - val url = buildUrl(params) + fun fetchTourInfo(params: TourSearchParams): TourResponse { + logger.info("지역 기반 관광 정보 조회 시작") - println("관광 정보 조회 API 호출: $url") + val url = buildUrl(params) + logger.info("Tour API URL 생성 : $url") - return try { - val jsonResponse = restTemplate.getForObject(url, String::class.java) - println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}") + /* + * runCatching: 예외를 Result로 감싸 예외를 던지지 않고 처리하는 유틸리티 함수 + * getOrNull(): 성공 시 응답 문자열을, 실패 시 null 반환 + * takeUnless { it.isNullOrBlank() }: 공백 응답을 걸러냄 + * ?.let { parseItems(it) } ?: emptyList(): 유효한 응답은 파싱, 아니면 빈 리스트 반환 + */ + val response = + runCatching { restTemplate.getForObject(url, String::class.java) } + .onFailure { logger.error("관광 정보 조회 실패", it) } + .getOrNull() + .takeUnless { it.isNullOrBlank() } + ?.let { parseItems(it) } - if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때 + return response ?: TourResponse(items = emptyList()) + } - val root = objectMapper.readTree(jsonResponse) // 문자열을 Jackson 트리 구조(JsonNode)로 변환 - val itemsNode = - root // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감 - .path("response") - .path("body") - .path("items") - .path("item") + private fun parseItems(json: String): TourResponse { + val root = objectMapper.readTree(json) - if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우 + // header.resultCode 값 추출위한 노스 탐색 과정 + val resultCode = + root + .path("response") + .path("header") + .path("resultCode") + .asText() - val firstItem = itemsNode.first() - TourResponse( - contentId = firstItem.path("contentid").asText(), - contentTypeId = firstItem.path("contenttypeid").asText(), - createdTime = firstItem.path("createdtime").asText(), - modifiedTime = firstItem.path("modifiedtime").asText(), - title = firstItem.path("title").asText(), - addr1 = firstItem.path("addr1").takeIf { it.isTextual }?.asText(), - areaCode = firstItem.path("areacode").takeIf { it.isTextual }?.asText(), - firstimage = firstItem.path("firstimage").takeIf { it.isTextual }?.asText(), - firstimage2 = firstItem.path("firstimage2").takeIf { it.isTextual }?.asText(), - mapX = firstItem.path("mapx").takeIf { it.isTextual }?.asText(), - mapY = firstItem.path("mapy").takeIf { it.isTextual }?.asText(), - mlevel = firstItem.path("mlevel").takeIf { it.isTextual }?.asText(), - sigunguCode = firstItem.path("sigungucode").takeIf { it.isTextual }?.asText(), - lDongRegnCd = firstItem.path("lDongRegnCd").takeIf { it.isTextual }?.asText(), - lDongSignguCd = firstItem.path("lDongSignguCd").takeIf { it.isTextual }?.asText(), - ) - } catch (e: Exception) { - println("관광 정보 조회 오류: ${e.message}") - null + // resultCode가 "0000"이 아닌 경우 체크 + if (resultCode != "0000") { + logger.warn("관광 정보 API resultCode={}", resultCode) + return TourResponse(items = emptyList()) } + + // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감 + val itemsNode = + root + .path("response") + .path("body") + .path("items") + .path("item") + + // 탐색 결과가 비어 있는 경우 + if (!itemsNode.isArray || itemsNode.isEmpty) return TourResponse(items = emptyList()) + + // itemsNode가 배열이므로 map으로 각 노드를 TourItem으로 변환 후 컨테이너로 감싼다. + val items = + itemsNode.map { node -> + TourItem( + contentId = node.path("contentid").asText(), + contentTypeId = node.path("contenttypeid").asText(), + createdTime = node.path("createdtime").asText(), + modifiedTime = node.path("modifiedtime").asText(), + title = node.path("title").asText(), + addr1 = node.path("addr1").takeIf { it.isTextual }?.asText(), + areaCode = node.path("areacode").takeIf { it.isTextual }?.asText(), + firstimage = node.path("firstimage").takeIf { it.isTextual }?.asText(), + firstimage2 = node.path("firstimage2").takeIf { it.isTextual }?.asText(), + mapX = node.path("mapx").takeIf { it.isTextual }?.asText(), + mapY = node.path("mapy").takeIf { it.isTextual }?.asText(), + mlevel = node.path("mlevel").takeIf { it.isTextual }?.asText(), + sigunguCode = node.path("sigungucode").takeIf { it.isTextual }?.asText(), + lDongRegnCd = node.path("lDongRegnCd").takeIf { it.isTextual }?.asText(), + lDongSignguCd = node.path("lDongSignguCd").takeIf { it.isTextual }?.asText(), + ) + } + + return TourResponse(items = items) } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt index 63a0a16..69db9a4 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt @@ -1,20 +1,26 @@ package com.back.koreaTravelGuide.domain.ai.tour.dto /** - * 9.25 양현준 + * 9.27 양현준 * 관광 정보 응답 DTO * API 매뉴얼에서 필수인 값은 NonNull로 지정. */ + data class TourResponse( - // 콘텐츠ID (고유 번호) + val items: List, +) + +// 관광 정보 단일 아이템 +data class TourItem( + // 콘텐츠ID (고유 번호, NonNull) val contentId: String, - // 관광타입 ID (12: 관광지, 14: 문화시설 ..) + // 관광타입 ID (12: 관광지, NonNull) val contentTypeId: String, - // 등록일 + // 등록일 (NonNull) val createdTime: String, - // 수정일 + // 수정일 (NonNull) val modifiedTime: String, - // 제목 + // 제목 (NonNull) val title: String, // 주소 val addr1: String?, @@ -32,7 +38,7 @@ data class TourResponse( val mlevel: String?, // 시군구코드 val sigunguCode: String?, - // 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로, + // 법정동 시도 코드 val lDongRegnCd: String?, // 법정동 시군구 코드 val lDongSignguCd: String?, diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt similarity index 50% rename from src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt rename to src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt index 87a8a03..2ae8604 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt @@ -1,20 +1,25 @@ package com.back.koreaTravelGuide.domain.ai.tour.dto /** - * 9.25 양현준 - * 관광 정보 호출용 파라미터 - * 기능상, 생략 가능한 필드는 생략 (arrange : 제목 순으로 정렬, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형) + * 9.27 양현준 + * API 요청 파라미터 + * 기능상, 생략 가능한 필드는 생략 (arrange : 제목 순, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형) */ -data class InternalData( +data class TourSearchParams( // 한 페이지 데이터 수, 10으로 지정 - val numOfRows: Int = 10, + val numOfRows: Int = DEFAULT_ROWS, // 페이지 번호, 1로 지정 - val pageNo: Int = 1, + val pageNo: Int = DEFAULT_PAGE, // 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...), - val contentTypeId: String? = "", + val contentTypeId: String? = null, // 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...) - val areaCode: String? = "", + val areaCode: String? = null, // 시군구코드, 미 입력시 전체 조회 - val sigunguCode: String? = "", -) + val sigunguCode: String? = null, +) { + companion object { + const val DEFAULT_ROWS = 10 + const val DEFAULT_PAGE = 1 + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt index 3fa04cf..7541b5c 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt @@ -1,4 +1,50 @@ package com.back.koreaTravelGuide.domain.ai.tour.service -// TODO: 관광 정보 캐싱 서비스 - 캐시 관리 및 데이터 제공 -class TourService +import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +// 09.26 양현준 +@Service +class TourService( + private val tourApiClient: TourApiClient, +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + // 관광 정보 조회 + fun fetchTours( + numOfRows: Int? = null, + pageNo: Int? = null, + contentTypeId: String? = null, + areaCode: String? = null, + sigunguCode: String? = null, + ): TourResponse { + // null 또는 비정상 값은 기본값으로 대체 + val request = + TourSearchParams( + numOfRows = numOfRows?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_ROWS, + pageNo = pageNo?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_PAGE, + contentTypeId = contentTypeId?.ifBlank { null } ?: "", + areaCode = areaCode?.ifBlank { null } ?: "", + sigunguCode = sigunguCode?.ifBlank { null } ?: "", + ) + + // request를 바탕으로 관광 정보 API 호출 + val tours = tourApiClient.fetchTourInfo(request) + + // 관광 정보 결과 로깅 + if (tours.items.isEmpty()) { + logger.info( + "관광 정보 없음: params={} / {} {}", + request.areaCode, + request.sigunguCode, + request.contentTypeId, + ) + } else { + logger.info("관광 정보 {}건 조회 성공", tours.items.size) + } + return tours + } +} diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt index a9d0e29..51d828a 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt @@ -29,7 +29,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -// 09.25 양현준 +// 09.26 양현준 @ExtendWith(SpringExtension::class) // 패키지 경로에서 메인 설정을 찾지 못하는 오류를 해결하기 위해 애플리케이션 클래스를 명시. @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @@ -57,8 +57,7 @@ class TourApiClientTest { tourApiClient = TourApiClient(restTemplate, objectMapper, serviceKey, apiUrl) } - // 첫 번째 관광 정보를 반환하는지. - @DisplayName("TourApiClient - fetchTourInfo") + @DisplayName("fetchTourInfo - 첫 번째 관광 정보를 반환하는지.") @Test fun testReturnsFirstTourInfo() { val params = InternalData(numOfRows = 2, pageNo = 1, areaCode = "1", sigunguCode = "7") @@ -73,8 +72,7 @@ class TourApiClientTest { assertEquals("7", result.sigunguCode) } - // item 배열이 비어 있으면 null을 돌려주는지. - @DisplayName("TourApiClient - fetchTourInfo") + @DisplayName("fetchTourInfo - item 배열이 비어 있으면 null을 돌려주는지.") @Test fun testReturnsNullWhenItemsMissing() { val params = InternalData(numOfRows = 1, pageNo = 1, areaCode = "1", sigunguCode = "7")