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/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/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/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) + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 57aa50f..5720bf1 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: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - profile + - email + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: + - name + - email + - profile_image + client-name: Naver + kakao: + 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}" + 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: 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")