Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.back.koreaTravelGuide.common.config

import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
import com.back.koreaTravelGuide.domain.ai.weather.dto.MidForecastDto
import com.back.koreaTravelGuide.domain.ai.weather.dto.TemperatureAndLandForecastDto
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.cache.CacheManager
import org.springframework.context.annotation.Bean
Expand All @@ -10,13 +13,20 @@ import org.springframework.data.redis.cache.RedisCacheConfiguration
import org.springframework.data.redis.cache.RedisCacheManager
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.RedisSerializationContext
import org.springframework.data.redis.serializer.StringRedisSerializer
import java.time.Duration

@Configuration
class RedisConfig {
@Bean
fun objectMapper(): ObjectMapper =
ObjectMapper().apply {
// Kotlin 모듈 등록 (data class 생성자 인식)
registerModule(KotlinModule.Builder().build())
}

@Bean
fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<String, String> {
val template = RedisTemplate<String, String>()
Expand All @@ -33,35 +43,76 @@ class RedisConfig {
}

@Bean
fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
// Kotlin data class 역직렬화 지원을 위한 ObjectMapper 생성
val objectMapper =
ObjectMapper().apply {
// Kotlin 모듈 등록 (data class 생성자 인식)
registerModule(KotlinModule.Builder().build())
// 타입 정보 보존을 위한 검증기 설정
activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Any::class.java)
.build(),
ObjectMapper.DefaultTyping.NON_FINAL,
fun cacheManager(
connectionFactory: RedisConnectionFactory,
objectMapper: ObjectMapper,
): CacheManager {

// 각 캐시 타입별 Serializer 생성
val tourResponseSerializer = Jackson2JsonRedisSerializer<TourResponse>(objectMapper, TourResponse::class.java)
val tourDetailResponseSerializer = Jackson2JsonRedisSerializer<TourDetailResponse>(objectMapper, TourDetailResponse::class.java)

// List<MidForecastDto> 타입을 위한 JavaType 생성
val midForecastListType =
objectMapper.typeFactory.constructCollectionType(
List::class.java,
MidForecastDto::class.java,
)
val midForecastListSerializer = Jackson2JsonRedisSerializer<List<MidForecastDto>>(objectMapper, midForecastListType)

// List<TemperatureAndLandForecastDto> 타입을 위한 JavaType 생성
val tempAndLandListType =
objectMapper.typeFactory.constructCollectionType(
List::class.java,
TemperatureAndLandForecastDto::class.java,
)
val tempAndLandListSerializer = Jackson2JsonRedisSerializer<List<TemperatureAndLandForecastDto>>(objectMapper, tempAndLandListType)

// 공통 키 Serializer
val keySerializer = RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())

// Tour 관련 캐시 설정 (TourResponse 타입)
val tourResponseConfig =
RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(keySerializer)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(tourResponseSerializer),
)
}
.entryTtl(Duration.ofHours(12))

val redisCacheConfiguration =
// Tour 상세 캐시 설정 (TourDetailResponse 타입)
val tourDetailConfig =
RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()),
.serializeKeysWith(keySerializer)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(tourDetailResponseSerializer),
)
.entryTtl(Duration.ofHours(12))

// 중기예보 캐시 설정 (List<MidForecastDto> 타입)
val midForecastConfig =
RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(keySerializer)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(midForecastListSerializer),
)
.entryTtl(Duration.ofHours(12))

// 기온/육상 예보 캐시 설정 (List<TemperatureAndLandForecastDto> 타입)
val tempAndLandConfig =
RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(keySerializer)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer(objectMapper),
),
RedisSerializationContext.SerializationPair.fromSerializer(tempAndLandListSerializer),
)
.entryTtl(Duration.ofHours(12))

return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(redisCacheConfiguration)
.withCacheConfiguration("tourAreaBased", tourResponseConfig)
.withCacheConfiguration("tourLocationBased", tourResponseConfig)
.withCacheConfiguration("tourDetail", tourDetailConfig)
.withCacheConfiguration("weatherMidFore", midForecastConfig)
.withCacheConfiguration("weatherTempAndLandFore", tempAndLandConfig)
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class SecurityConfig(
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
val isDev = environment.activeProfiles.contains("dev")
val isDev = environment.getProperty("spring.profiles.active")?.contains("dev") == true ||
environment.activeProfiles.contains("dev")

http {
csrf { disable() }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.back.koreaTravelGuide.domain.ai.aiChat.controller

import com.back.koreaTravelGuide.common.ApiResponse
import com.back.koreaTravelGuide.common.security.getUserId
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
Expand All @@ -9,14 +10,14 @@ 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.security.core.Authentication
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
Expand All @@ -26,8 +27,9 @@ class AiChatController(
) {
@GetMapping("/sessions")
fun getSessions(
@RequestParam userId: Long,
authentication: Authentication?,
): ResponseEntity<ApiResponse<List<SessionsResponse>>> {
val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1
val sessions =
aiChatService.getSessions(userId).map {
SessionsResponse.from(it)
Expand All @@ -37,8 +39,9 @@ class AiChatController(

@PostMapping("/sessions")
fun createSession(
@RequestParam userId: Long,
authentication: Authentication?,
): ResponseEntity<ApiResponse<SessionsResponse>> {
val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1
val session = aiChatService.createSession(userId)
val response = SessionsResponse.from(session)
return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 생성되었습니다.", response))
Expand All @@ -47,17 +50,19 @@ class AiChatController(
@DeleteMapping("/sessions/{sessionId}")
fun deleteSession(
@PathVariable sessionId: Long,
@RequestParam userId: Long,
authentication: Authentication?,
): ResponseEntity<ApiResponse<Unit>> {
val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1
aiChatService.deleteSession(sessionId, userId)
return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 삭제되었습니다."))
}

@GetMapping("/sessions/{sessionId}/messages")
fun getSessionMessages(
@PathVariable sessionId: Long,
@RequestParam userId: Long,
authentication: Authentication?,
): ResponseEntity<ApiResponse<List<SessionMessagesResponse>>> {
val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1
val messages = aiChatService.getSessionMessages(sessionId, userId)
val response =
messages.map {
Expand All @@ -69,9 +74,10 @@ class AiChatController(
@PostMapping("/sessions/{sessionId}/messages")
fun sendMessage(
@PathVariable sessionId: Long,
@RequestParam userId: Long,
authentication: Authentication?,
@RequestBody request: AiChatRequest,
): ResponseEntity<ApiResponse<AiChatResponse>> {
val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1
val (userMessage, aiMessage) = aiChatService.sendMessage(sessionId, userId, request.message)
val response =
AiChatResponse(
Expand All @@ -84,9 +90,10 @@ class AiChatController(
@PatchMapping("/sessions/{sessionId}/title")
fun updateSessionTitle(
@PathVariable sessionId: Long,
@RequestParam userId: Long,
authentication: Authentication?,
@RequestBody request: UpdateSessionTitleRequest,
): ResponseEntity<ApiResponse<UpdateSessionTitleResponse>> {
val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1
val updatedSession = aiChatService.updateSessionTitle(sessionId, userId, request.newTitle)
val response =
UpdateSessionTitleResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import com.back.koreaTravelGuide.common.logging.log
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams
import com.back.koreaTravelGuide.domain.ai.tour.service.TourService
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.ai.tool.annotation.Tool
import org.springframework.ai.tool.annotation.ToolParam
import org.springframework.stereotype.Component

@Component
class TourTool(
private val tourService: TourService,
private val objectMapper: ObjectMapper,
) {
/**
* fetchTours - 지역기반 관광정보 조회
Expand Down Expand Up @@ -47,8 +49,14 @@ class TourTool(
val tourParams = tourService.parseParams(contentTypeId, areaAndSigunguCode)
val tourInfo = tourService.fetchTours(tourParams)

log.info("✅ [TOOL RESULT] getAreaBasedTourInfo - 결과: ${tourInfo.toString().take(100)}...")
return tourInfo.toString() ?: "지역기반 관광정보 조회를 가져올 수 없습니다."
return try {
val result = tourInfo.let { objectMapper.writeValueAsString(it) }
log.info("✅ [TOOL RESULT] getAreaBasedTourInfo - 결과: ${result.take(100)}...")
result
} catch (e: Exception) {
log.error("❌ [TOOL ERROR] getAreaBasedTourInfo - 예외 발생", e)
"지역기반 관광정보 조회를 가져올 수 없습니다."
}
}

/**
Expand Down Expand Up @@ -98,8 +106,14 @@ class TourTool(
val locationBasedParams = TourLocationBasedParams(mapX, mapY, radius)
val tourLocationBasedInfo = tourService.fetchLocationBasedTours(tourParams, locationBasedParams)

log.info("✅ [TOOL RESULT] getLocationBasedTourInfo - 결과: ${tourLocationBasedInfo.toString().take(100)}...")
return tourLocationBasedInfo.toString() ?: "위치기반 관광정보 조회를 가져올 수 없습니다."
return try {
val result = tourLocationBasedInfo.let { objectMapper.writeValueAsString(it) }
log.info("✅ [TOOL RESULT] getLocationBasedTourInfo - 결과: ${result.take(100)}...")
result
} catch (e: Exception) {
log.error("❌ [TOOL ERROR] getLocationBasedTourInfo - 예외 발생", e)
"위치기반 관광정보 조회를 가져올 수 없습니다."
}
}

/**
Expand All @@ -123,7 +137,13 @@ class TourTool(
val tourDetailParams = TourDetailParams(contentId)
val tourDetailInfo = tourService.fetchTourDetail(tourDetailParams)

log.info("✅ [TOOL RESULT] getTourDetailInfo - 결과: ${tourDetailInfo.toString().take(100)}...")
return tourDetailInfo.toString() ?: "관광정보 상세조회를 가져올 수 없습니다."
return try {
val result = tourDetailInfo.let { objectMapper.writeValueAsString(it) }
log.info("✅ [TOOL RESULT] getTourDetailInfo - 결과: ${result.take(100)}...")
result
} catch (e: Exception) {
log.error("❌ [TOOL ERROR] getTourDetailInfo - 예외 발생", e)
"관광정보 상세조회를 가져올 수 없습니다."
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.back.koreaTravelGuide.domain.ai.tour.dto

import com.fasterxml.jackson.annotation.JsonProperty

/**
* 9.27 양현준
* 관광 정보 응답 DTO
Expand Down Expand Up @@ -41,7 +43,9 @@ data class TourItem(
// 시군구코드
val sigunguCode: String?,
// 법정동 시도 코드
@get:JsonProperty("lDongRegnCd")
val lDongRegnCd: String?,
// 법정동 시군구 코드
@get:JsonProperty("lDongSignguCd")
val lDongSignguCd: String?,
)