Skip to content

Commit e3f713c

Browse files
authored
Tour 도메인 완성, yaml 파일 생성 (#70)
* feat(be): 위치 기반, 공통 정보 조회 기능 추가 (#49) * feat(be): 공통 정보 조회 Dto 추가 (#49) * refactor(be): TourParams와 TourDetailParams로 Dto 이름 변경 * refactor(be): 마찬가지로 LocationBasedParams로 Dto 이름 변경 * feat(be): yml, build.gradle 환경 변수(#49) * feat(be): yml, gradle 수정 및 tool 연계 위한 client, service 수정 (#49)
1 parent d3c52b3 commit e3f713c

File tree

12 files changed

+504
-106
lines changed

12 files changed

+504
-106
lines changed

build.gradle.kts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ ktlint {
117117
buildConfig {
118118
useKotlinOutput()
119119

120+
val areaCodes =
121+
file("src/main/resources/area-codes.yml")
122+
.readText()
123+
.substringAfter("codes:")
124+
.lines()
125+
.filter { it.contains(":") }
126+
.joinToString(", ") { line ->
127+
val parts = line.split(":")
128+
"${parts[0].trim()}: ${parts[1].trim().replace("\"", "")}"
129+
}
130+
131+
val contentTypeCodes =
132+
file("src/main/resources/content-type-id.yml")
133+
.readText()
134+
.substringAfter("codes:")
135+
.lines()
136+
.filter { it.contains(":") }
137+
.joinToString(", ") { line ->
138+
val parts = line.split(":")
139+
"${parts[0].trim()}: ${parts[1].trim().replace("\"", "")}"
140+
}
141+
120142
val regionCodes =
121143
file("src/main/resources/region-codes.yml")
122144
.readText()
@@ -140,6 +162,8 @@ buildConfig {
140162
.substringAfter("ai-fallback: \"")
141163
.substringBefore("\"")
142164

165+
buildConfigField("String", "AREA_CODES_DESCRIPTION", "\"\"\"$areaCodes\"\"\"")
166+
buildConfigField("String", "CONTENT_TYPE_CODES_DESCRIPTION", "\"\"\"$contentTypeCodes\"\"\"")
143167
buildConfigField("String", "REGION_CODES_DESCRIPTION", "\"\"\"$regionCodes\"\"\"")
144168
buildConfigField("String", "KOREA_TRAVEL_GUIDE_SYSTEM", "\"\"\"$systemPrompt\"\"\"")
145169
buildConfigField("String", "AI_ERROR_FALLBACK", "\"\"\"$errorPrompt\"\"\"")
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.back.koreaTravelGuide.domain.ai.aiChat.tool
2+
3+
import com.back.backend.BuildConfig
4+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams
5+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams
6+
import com.back.koreaTravelGuide.domain.ai.tour.service.TourService
7+
import org.springframework.ai.tool.annotation.Tool
8+
import org.springframework.ai.tool.annotation.ToolParam
9+
import org.springframework.stereotype.Component
10+
11+
@Component
12+
class TourToolExample(
13+
private val tourService: TourService,
14+
) {
15+
/**
16+
* fetchTours - 지역기반 관광정보 조회
17+
* 케이스 : 부산광역시 사하구에 있는 관광지 조회
18+
* "areacode": "6" 부산
19+
* "sigungucode": "10" 사하구
20+
* "contenttypeid": "12" 관광지
21+
*/
22+
23+
@Tool(description = "areaBasedList2 : 지역기반 관광정보 조회, 특정 지역의 관광 정보 조회")
24+
fun getTourInfo(
25+
@ToolParam(description = BuildConfig.CONTENT_TYPE_CODES_DESCRIPTION, required = true)
26+
contentTypeId: String,
27+
@ToolParam(description = BuildConfig.AREA_CODES_DESCRIPTION, required = true)
28+
areaAndSigunguCode: String,
29+
): String {
30+
// areaAndSigunguCode를 areaCode와 sigunguCode로 분리
31+
val tourParams = tourService.parseParams(contentTypeId, areaAndSigunguCode)
32+
33+
val tourInfo = tourService.fetchTours(tourParams)
34+
35+
return tourInfo.toString() ?: "지역기반 관광정보 조회를 가져올 수 없습니다."
36+
}
37+
38+
/**
39+
* fetchLocationBasedTours - 위치기반 관광정보 조회
40+
* 케이스 : 서울특별시 중구 명동 근처 100m 이내에있는 음식점 조회
41+
* "areacode": "1" 서울
42+
* "sigungucode": "24" 중구
43+
* "contenttypeid": "39" 음식점
44+
* "mapx": "126.98375",
45+
* "mapy": "37.563446",
46+
* "radius": "100",
47+
*/
48+
49+
@Tool(description = "locationBasedList2 : 위치기반 관광정보 조회, 특정 위치 기반의 관광 정보 조회")
50+
fun get(
51+
@ToolParam(description = BuildConfig.CONTENT_TYPE_CODES_DESCRIPTION, required = true)
52+
contentTypeId: String,
53+
@ToolParam(description = BuildConfig.AREA_CODES_DESCRIPTION, required = true)
54+
areaAndSigunguCode: String,
55+
@ToolParam(description = "WGS84 경도", required = true)
56+
mapX: String = "126.98375",
57+
@ToolParam(description = "WGS84 위도", required = true)
58+
mapY: String = "37.563446",
59+
@ToolParam(description = "검색 반경(m)", required = true)
60+
radius: String = "100",
61+
): String {
62+
// areaAndSigunguCode를 areaCode와 sigunguCode로 분리
63+
val tourParams = tourService.parseParams(contentTypeId, areaAndSigunguCode)
64+
val locationBasedParams = TourLocationBasedParams(mapX, mapY, radius)
65+
66+
val tourLocationBasedInfo = tourService.fetchLocationBasedTours(tourParams, locationBasedParams)
67+
68+
return tourLocationBasedInfo.toString() ?: "위치기반 관광정보 조회를 가져올 수 없습니다."
69+
}
70+
71+
/**
72+
* fetchTourDetail - 상세조회
73+
* 케이스 : 콘텐츠ID가 “126128”인 관광정보의 “상베 정보” 조회
74+
* "contentid": "127974",
75+
*/
76+
77+
@Tool(description = "detailCommon2 : 관광정보 상세조회, 특정 관광 정보의 상세 정보 조회")
78+
fun get(
79+
@ToolParam(description = "Tour API Item에 각각 할당된 contentId", required = true)
80+
contentId: String = "127974",
81+
): String {
82+
val tourDetailParams = TourDetailParams(contentId)
83+
84+
val tourDetailInfo = tourService.fetchTourDetail(tourDetailParams)
85+
86+
return tourDetailInfo.toString() ?: "관광정보 상세조회를 가져올 수 없습니다."
87+
}
88+
}
Lines changed: 127 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.client
22

3+
import com.back.koreaTravelGuide.common.logging.log
4+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailItem
5+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams
6+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse
37
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem
8+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams
9+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams
410
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
5-
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
11+
import com.fasterxml.jackson.databind.JsonNode
612
import com.fasterxml.jackson.databind.ObjectMapper
7-
import org.slf4j.LoggerFactory
813
import org.springframework.beans.factory.annotation.Value
914
import org.springframework.stereotype.Component
1015
import org.springframework.web.client.RestTemplate
16+
import org.springframework.web.reactive.function.server.RequestPredicates.queryParam
1117
import org.springframework.web.util.UriComponentsBuilder
1218
import java.net.URI
1319

@@ -19,19 +25,14 @@ class TourApiClient(
1925
@Value("\${tour.api.key}") private val serviceKey: String,
2026
@Value("\${tour.api.base-url}") private val apiUrl: String,
2127
) {
22-
// println 대신 SLF4J 로거 사용
23-
private val logger = LoggerFactory.getLogger(TourApiClient::class.java)
24-
2528
// 요청 URL 구성
26-
private fun buildUrl(params: TourSearchParams): URI =
29+
private fun buildUrl(params: TourParams): URI =
2730
UriComponentsBuilder.fromUri(URI.create(apiUrl))
2831
.path("/areaBasedList2")
2932
.queryParam("serviceKey", serviceKey)
3033
.queryParam("MobileOS", "WEB")
3134
.queryParam("MobileApp", "KoreaTravelGuide")
3235
.queryParam("_type", "json")
33-
.queryParam("numOfRows", params.numOfRows)
34-
.queryParam("pageNo", params.pageNo)
3536
.queryParam("contentTypeId", params.contentTypeId)
3637
.queryParam("areaCode", params.areaCode)
3738
.queryParam("sigunguCode", params.sigunguCode)
@@ -40,59 +41,84 @@ class TourApiClient(
4041
.toUri()
4142

4243
// 지역 기반 관광 정보 조회 (areaBasedList2)
43-
fun fetchTourInfo(params: TourSearchParams): TourResponse {
44-
logger.info("지역 기반 관광 정보 조회 시작")
45-
44+
fun fetchTourInfo(params: TourParams): TourResponse {
4645
val url = buildUrl(params)
47-
logger.info("Tour API URL 생성 : $url")
48-
49-
/*
50-
* runCatching: 예외를 Result로 감싸 예외를 던지지 않고 처리하는 유틸리티 함수
51-
* getOrNull(): 성공 시 응답 문자열을, 실패 시 null 반환
52-
* takeUnless { it.isNullOrBlank() }: 공백 응답을 걸러냄
53-
* ?.let { parseItems(it) } ?: emptyList(): 유효한 응답은 파싱, 아니면 빈 리스트 반환
54-
*/
55-
val response =
46+
47+
val body =
5648
runCatching { restTemplate.getForObject(url, String::class.java) }
57-
.onFailure { logger.error("관광 정보 조회 실패", it) }
49+
.onFailure { log.error("관광 정보 조회 실패", it) }
5850
.getOrNull()
59-
.takeUnless { it.isNullOrBlank() }
60-
?.let { parseItems(it) }
6151

62-
return response ?: TourResponse(items = emptyList())
52+
return body
53+
.takeUnless { it.isNullOrBlank() }
54+
?.let { parseItems(it) }
55+
?: TourResponse(items = emptyList())
6356
}
6457

65-
private fun parseItems(json: String): TourResponse {
66-
val root = objectMapper.readTree(json)
58+
// 위치기반 관광정보 조회 (locationBasedList2)
59+
fun fetchLocationBasedTours(
60+
tourParams: TourParams,
61+
locationParams: TourLocationBasedParams,
62+
): TourResponse {
63+
val url =
64+
UriComponentsBuilder.fromUri(URI.create(apiUrl))
65+
.path("/locationBasedList2")
66+
.queryParam("serviceKey", serviceKey)
67+
.queryParam("MobileOS", "WEB")
68+
.queryParam("MobileApp", "KoreaTravelGuide")
69+
.queryParam("_type", "json")
70+
.queryParam("mapX", locationParams.mapX)
71+
.queryParam("mapY", locationParams.mapY)
72+
.queryParam("radius", locationParams.radius)
73+
.queryParam("contentTypeId", tourParams.contentTypeId)
74+
.queryParam("areaCode", tourParams.areaCode)
75+
.queryParam("sigunguCode", tourParams.sigunguCode)
76+
.build()
77+
.encode()
78+
.toUri()
6779

68-
// header.resultCode 값 추출위한 노스 탐색 과정
69-
val resultCode =
70-
root
71-
.path("response")
72-
.path("header")
73-
.path("resultCode")
74-
.asText()
80+
val body =
81+
runCatching { restTemplate.getForObject(url, String::class.java) }
82+
.onFailure { log.error("위치기반 관광 정보 조회 실패", it) }
83+
.getOrNull()
7584

76-
// resultCode가 "0000"이 아닌 경우 체크
77-
if (resultCode != "0000") {
78-
logger.warn("관광 정보 API resultCode={}", resultCode)
79-
return TourResponse(items = emptyList())
80-
}
85+
return body
86+
.takeUnless { it.isNullOrBlank() }
87+
?.let { parseItems(it) }
88+
?: TourResponse(items = emptyList())
89+
}
8190

82-
// path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
83-
val itemsNode =
84-
root
85-
.path("response")
86-
.path("body")
87-
.path("items")
88-
.path("item")
91+
// 공통정보 조회 (detailCommon2)
92+
fun fetchTourDetail(params: TourDetailParams): TourDetailResponse {
93+
val url =
94+
UriComponentsBuilder.fromUri(URI.create(apiUrl))
95+
.path("/detailCommon2")
96+
.queryParam("serviceKey", serviceKey)
97+
.queryParam("MobileOS", "WEB")
98+
.queryParam("MobileApp", "KoreaTravelGuide")
99+
.queryParam("_type", "json")
100+
.queryParam("contentId", params.contentId)
101+
.build()
102+
.encode()
103+
.toUri()
89104

90-
// 탐색 결과가 비어 있는 경우
91-
if (!itemsNode.isArray || itemsNode.isEmpty) return TourResponse(items = emptyList())
105+
val body =
106+
runCatching { restTemplate.getForObject(url, String::class.java) }
107+
.onFailure { log.error("공통정보 조회 실패", it) }
108+
.getOrNull()
109+
110+
return body
111+
.takeUnless { it.isNullOrBlank() }
112+
?.let { parseDetailItems(it) }
113+
?: TourDetailResponse(items = emptyList())
114+
}
115+
116+
private fun parseItems(json: String): TourResponse {
117+
val itemNodes = extractItemNodes(json, "관광 정보")
118+
if (itemNodes.isEmpty()) return TourResponse(items = emptyList())
92119

93-
// itemsNode가 배열이므로 map으로 각 노드를 TourItem으로 변환 후 컨테이너로 감싼다.
94120
val items =
95-
itemsNode.map { node ->
121+
itemNodes.map { node ->
96122
TourItem(
97123
contentId = node.path("contentid").asText(),
98124
contentTypeId = node.path("contenttypeid").asText(),
@@ -105,6 +131,7 @@ class TourApiClient(
105131
firstimage2 = node.path("firstimage2").takeIf { it.isTextual }?.asText(),
106132
mapX = node.path("mapx").takeIf { it.isTextual }?.asText(),
107133
mapY = node.path("mapy").takeIf { it.isTextual }?.asText(),
134+
distance = node.path("dist").takeIf { it.isTextual }?.asText(),
108135
mlevel = node.path("mlevel").takeIf { it.isTextual }?.asText(),
109136
sigunguCode = node.path("sigungucode").takeIf { it.isTextual }?.asText(),
110137
lDongRegnCd = node.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
@@ -114,4 +141,55 @@ class TourApiClient(
114141

115142
return TourResponse(items = items)
116143
}
144+
145+
private fun parseDetailItems(json: String): TourDetailResponse {
146+
val itemNodes = extractItemNodes(json, "공통정보")
147+
if (itemNodes.isEmpty()) return TourDetailResponse(items = emptyList())
148+
149+
val items =
150+
itemNodes.map { node ->
151+
TourDetailItem(
152+
contentId = node.path("contentid").asText(),
153+
title = node.path("title").asText(),
154+
overview = node.path("overview").takeIf { it.isTextual }?.asText(),
155+
addr1 = node.path("addr1").takeIf { it.isTextual }?.asText(),
156+
mapX = node.path("mapx").takeIf { it.isTextual }?.asText(),
157+
mapY = node.path("mapy").takeIf { it.isTextual }?.asText(),
158+
firstImage = node.path("firstimage").takeIf { it.isTextual }?.asText(),
159+
tel = node.path("tel").takeIf { it.isTextual }?.asText(),
160+
homepage = node.path("homepage").takeIf { it.isTextual }?.asText(),
161+
)
162+
}
163+
164+
return TourDetailResponse(items = items)
165+
}
166+
167+
private fun extractItemNodes(
168+
json: String,
169+
apiName: String,
170+
): List<JsonNode> {
171+
val root = objectMapper.readTree(json)
172+
val resultCode =
173+
root
174+
.path("response")
175+
.path("header")
176+
.path("resultCode")
177+
.asText()
178+
179+
if (resultCode != "0000") {
180+
log.warn("{} API resultCode={}", apiName, resultCode)
181+
return emptyList()
182+
}
183+
184+
val itemsNode =
185+
root
186+
.path("response")
187+
.path("body")
188+
.path("items")
189+
.path("item")
190+
191+
if (!itemsNode.isArray || itemsNode.isEmpty) return emptyList()
192+
193+
return itemsNode.map { it }
194+
}
117195
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.back.koreaTravelGuide.domain.ai.tour.dto
2+
3+
/**
4+
* 공통정보(detailCommon2) 조회 요청 파라미터.
5+
* contentId는 필수, 페이지 관련 값은 기본값으로 1페이지/10건을 사용한다.
6+
*/
7+
data class TourDetailParams(
8+
val contentId: String,
9+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.back.koreaTravelGuide.domain.ai.tour.dto
2+
3+
data class TourDetailResponse(
4+
val items: List<TourDetailItem>,
5+
)
6+
7+
data class TourDetailItem(
8+
val contentId: String,
9+
val title: String,
10+
val overview: String?,
11+
val addr1: String?,
12+
val mapX: String?,
13+
val mapY: String?,
14+
val firstImage: String?,
15+
val tel: String?,
16+
val homepage: String?,
17+
)

0 commit comments

Comments
 (0)