From ee7b78ccc7544f1a527aa1c4f8759a419d3f7d5a Mon Sep 17 00:00:00 2001 From: Mrbaeksang Date: Thu, 2 Oct 2025 16:38:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?tourtool=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A0=88=EB=94=94=EC=8A=A4=20=EC=84=A4=EC=A0=95=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/RedisConfig.kt | 95 ++++++++++++++----- .../common/security/SecurityConfig.kt | 3 +- .../ai/aiChat/controller/AiChatController.kt | 21 ++-- .../domain/ai/aiChat/tool/TourTool.kt | 32 +++++-- .../domain/ai/tour/dto/TourResponse.kt | 4 + 5 files changed, 119 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt index 4885ac3..d60a2a6 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt @@ -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 @@ -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 { val template = RedisTemplate() @@ -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(objectMapper, TourResponse::class.java) + val tourDetailResponseSerializer = Jackson2JsonRedisSerializer(objectMapper, TourDetailResponse::class.java) + + // List 타입을 위한 JavaType 생성 + val midForecastListType = + objectMapper.typeFactory.constructCollectionType( + List::class.java, + MidForecastDto::class.java, + ) + val midForecastListSerializer = Jackson2JsonRedisSerializer>(objectMapper, midForecastListType) + + // List 타입을 위한 JavaType 생성 + val tempAndLandListType = + objectMapper.typeFactory.constructCollectionType( + List::class.java, + TemperatureAndLandForecastDto::class.java, + ) + val tempAndLandListSerializer = Jackson2JsonRedisSerializer>(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 타입) + val midForecastConfig = + RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(keySerializer) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(midForecastListSerializer), + ) + .entryTtl(Duration.ofHours(12)) + + // 기온/육상 예보 캐시 설정 (List 타입) + 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() } } 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 3d123ec..5e785fb 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt @@ -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() } 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 bfbf1ba..c537f4f 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,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 @@ -9,6 +10,7 @@ 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 @@ -16,7 +18,6 @@ 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 @@ -26,8 +27,9 @@ class AiChatController( ) { @GetMapping("/sessions") fun getSessions( - @RequestParam userId: Long, + authentication: Authentication?, ): ResponseEntity>> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val sessions = aiChatService.getSessions(userId).map { SessionsResponse.from(it) @@ -37,8 +39,9 @@ class AiChatController( @PostMapping("/sessions") fun createSession( - @RequestParam userId: Long, + authentication: Authentication?, ): ResponseEntity> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val session = aiChatService.createSession(userId) val response = SessionsResponse.from(session) return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 생성되었습니다.", response)) @@ -47,8 +50,9 @@ class AiChatController( @DeleteMapping("/sessions/{sessionId}") fun deleteSession( @PathVariable sessionId: Long, - @RequestParam userId: Long, + authentication: Authentication?, ): ResponseEntity> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 aiChatService.deleteSession(sessionId, userId) return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 삭제되었습니다.")) } @@ -56,8 +60,9 @@ class AiChatController( @GetMapping("/sessions/{sessionId}/messages") fun getSessionMessages( @PathVariable sessionId: Long, - @RequestParam userId: Long, + authentication: Authentication?, ): ResponseEntity>> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val messages = aiChatService.getSessionMessages(sessionId, userId) val response = messages.map { @@ -69,9 +74,10 @@ class AiChatController( @PostMapping("/sessions/{sessionId}/messages") fun sendMessage( @PathVariable sessionId: Long, - @RequestParam userId: Long, + authentication: Authentication?, @RequestBody request: AiChatRequest, ): ResponseEntity> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val (userMessage, aiMessage) = aiChatService.sendMessage(sessionId, userId, request.message) val response = AiChatResponse( @@ -84,9 +90,10 @@ class AiChatController( @PatchMapping("/sessions/{sessionId}/title") fun updateSessionTitle( @PathVariable sessionId: Long, - @RequestParam userId: Long, + authentication: Authentication?, @RequestBody request: UpdateSessionTitleRequest, ): ResponseEntity> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val updatedSession = aiChatService.updateSessionTitle(sessionId, userId, request.newTitle) val response = UpdateSessionTitleResponse( diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourTool.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourTool.kt index fa792f0..7dc3194 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourTool.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourTool.kt @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,7 @@ import org.springframework.stereotype.Component @Component class TourTool( private val tourService: TourService, + private val objectMapper: ObjectMapper, ) { /** * fetchTours - 지역기반 관광정보 조회 @@ -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) + "지역기반 관광정보 조회를 가져올 수 없습니다." + } } /** @@ -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) + "위치기반 관광정보 조회를 가져올 수 없습니다." + } } /** @@ -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) + "관광정보 상세조회를 가져올 수 없습니다." + } } } 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 f058aaa..c250e6d 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,5 +1,7 @@ package com.back.koreaTravelGuide.domain.ai.tour.dto +import com.fasterxml.jackson.annotation.JsonProperty + /** * 9.27 양현준 * 관광 정보 응답 DTO @@ -41,7 +43,9 @@ data class TourItem( // 시군구코드 val sigunguCode: String?, // 법정동 시도 코드 + @get:JsonProperty("lDongRegnCd") val lDongRegnCd: String?, // 법정동 시군구 코드 + @get:JsonProperty("lDongSignguCd") val lDongSignguCd: String?, ) From 38eacc9b0c01eaf04a270369327c0db2a066dc9a Mon Sep 17 00:00:00 2001 From: Mrbaeksang Date: Thu, 9 Oct 2025 13:10:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(be):=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=ED=88=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95=C3=AB=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=84=EC=B2=B4=20=EB=A6=AC=ED=8C=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A6=AC=EB=93=9C=EB=AF=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 133 ++++++++++++++++++ .../common/config/RedisConfig.kt | 1 - .../common/security/SecurityConfig.kt | 5 +- .../ai/aiChat/controller/AiChatController.kt | 20 ++- src/main/resources/prompts.yml | 10 ++ 5 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 docs/README.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7e616e2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,133 @@ +# 한국 여행 가이드 백엔드 (포트폴리오 요약) + +여행자를 위한 AI 기반 맞춤 가이드를 목표로 한 백엔드 프로젝트입니다. Kotlin + Spring Boot를 중심으로 도메인 주도 설계(DDD), Spring AI, OAuth 인증, Redis 캐시, WebSocket 실시간 채팅을 결합해 MVP를 완성했습니다. 이 문서는 후보자 관점에서 시스템 전반을 빠르게 이해하도록 구성한 하이라이트 버전입니다. + +--- + +## 1. Product Vision & User Journey +- **타깃 사용자**: 한국 여행을 준비하는 게스트와 현지 가이드, 그리고 AI 여행 도우미를 통해 기본 안내를 받고 싶은 사용자. +- **핵심 플로우** + 1. Google/Kakao/Naver OAuth → 최초 로그인 시 역할(게스트/가이드) 선택. + 2. AI 여행 챗봇에게 날씨/관광지 정보를 요청하거나 투어를 추천받음. + 3. 가이드-게스트 1:1 채팅방을 개설하고 WebSocket으로 대화. + 4. AI 세션 및 가이드에 대한 평가를 남겨 품질을 축적. +- **UX 목표**: 실시간 현지 연결 + 신뢰할 수 있는 정보(공공 데이터 + 날씨 API) + 지속적인 개선을 위한 평가 데이터 확보. + +--- +## 2. System Snapshot +- **언어/런타임**: Kotlin 1.9.25, Java 21 +- **프레임워크**: Spring Boot 3.4.1, Spring Data JPA, Spring Security, Spring Web/WebFlux +- **AI 스택**: Spring AI 1.1.0-M2, OpenRouter Chat Completions, JDBC ChatMemory(대화 50턴 보존) +- **데이터 저장소**: PostgreSQL (prod) / H2 (dev), Redis (캐시 & 토큰 블랙리스트) +- **인프라 구성**: Dev profile는 H2 + DevTools + 전체 허용 CORS, Prod profile는 JWT 필터 + OAuth2 로그인 +- **문서화/도구**: SpringDoc OpenAPI, ktlint, Actuator, BuildConfig(정적 데이터 코드 생성) + +```text +src/main/kotlin/com/back/koreaTravelGuide +├── common/ # 공통 설정, 보안, 예외, 로깅 +├── domain/ +│ ├── auth/ # OAuth, JWT, 역할 선택, 토큰 재발급 +│ ├── user/ # 프로필 CRUD, 역할 관리 +│ ├── ai/ +│ │ ├── aiChat/ # Spring AI + 도구 + 세션/메시지 저장 +│ │ ├── tour/ # 한국관광공사 TourAPI 연동 + 캐시 +│ │ └── weather/ # 기상청 중기예보 + 스케줄 캐시 갱신 +│ ├── userChat/ # 게스트-가이드 WebSocket 채팅 & REST +│ └── rate/ # 가이드/AI 평가 및 통계 +└── resources/ + ├── application*.yml + ├── prompts.yml, area-codes.yml, region-codes.yml + └── org/springframework/ai/chat/memory/... (JDBC 스키마) +``` + +--- +## 3. Core Domains & What They Deliver + +### 3.1 Auth & Identity +- Google/Kakao/Naver OAuth2 로그인 → `CustomOAuth2UserService`가 공급자별 프로필을 통일. +- 최초 로그인 사용자는 `ROLE_PENDING` → `/api/auth/role`에서 게스트/가이드 선택 후 Access Token 발급. +- Refresh Token은 **HttpOnly Secure Cookie + Redis 저장**으로 관리, `AuthService.logout()`은 Access Token을 블랙리스트에 등록. +- Dev profile은 H2 + 토큰 필터 비활성화로 프런트 개발 속도 확보, Prod profile은 JWT 필터·OAuth 성공 핸들러·세션 stateless 모드 적용. + +### 3.2 AI Travel Assistant (`domain.ai.aiChat`) +- Spring AI `ChatClient` + JDBC ChatMemory → 세션별 50턴 대화 히스토리 유지, 재접속 시 맥락 이어받기. +- `TourTool`, `WeatherTool`을 기본 Tool로 주입해 LLM이 공공 데이터 API를 직접 호출. +- 첫 사용자 메시지 이후 `aiUpdateSessionTitle()`이 자동 요약 제목 생성, 오류 시 `BuildConfig.AI_ERROR_FALLBACK`으로 graceful degrade. +- 메시지 저장소는 `AiChatMessageRepository` (JPA)로 구성, 세션 생성/삭제/메시지 조회 API 지원. + +### 3.3 Public Data Integrations +- **Tour API**: 한국관광공사 OpenAPI 호출 (`TourApiClient`), 주요 API 3종(areaBased, locationBased, detailCommon)을 지원하고 `@Cacheable` + Redis Serializer로 응답 캐시. +- **Weather API**: 기상청 중기예보/기온/강수 데이터를 RestTemplate 기반으로 호출, DTO 파서로 정제, 12시간 TTL 캐시 및 `@Scheduled` 캐시 무효화. +- BuildConfig 플러그인이 `area-codes.yml`, `region-codes.yml`, `prompts.yml` 내용을 상수로 노출해 Tool 설명에 바로 활용 가능. + +### 3.4 Guest ↔ Guide Chat (`domain.userChat`) +- REST + STOMP WebSocket 하이브리드 구조. `/api/userchat/rooms`로 채팅방 CRUD, `/ws/userchat` 엔드포인트로 실시간 메시지 전달. +- STOMP CONNECT 단계에서 `UserChatStompAuthChannelInterceptor`가 JWT를 검증하고 `Principal`을 주입 → 명시적 인증 강제. +- 메시지 API는 커서 기반 페이징(최신/after)과 STOMP 브로드캐스트를 모두 제공, 채팅방 마지막 메시지 시각을 업데이트해 리스트 정렬. + +### 3.5 Rating & Reputation (`domain.rate`) +- 게스트는 가이드를, 사용자 본인은 AI 세션을 평가. `RateService`가 중복 평가 시 수정(Update), 최초면 Insert. +- 가이드 전용 대시보드 API(`/api/rate/guides/my`)는 평균/총 건수/리스트를 묶어서 반환. 관리자용 API는 AI 세션 평가 전체 조회. + +### 3.6 User Profiles +- `/api/users/me`에서 닉네임·프로필 이미지 업데이트 지원. +- 삭제 시 연관 데이터 정리는 JPA cascade로 처리, NoSuchElementException/IllegalStateException을 `GlobalExceptionHandler`가 표준 응답으로 래핑. + +--- +## 4. Architecture & Infrastructure Notes +- **DDD 패키지 구성**: 도메인 수준의 `entity/repository/service/controller/dto` 분리 + 공통 계층(`common/*`)으로 횡단 관심사 관리. +- **Persistence**: 표준 JPA + Kotlin data class, 세션/메시지/평가 엔티티는 soft constraint를 service 계층에서 검증. +- **Caching 전략** + - Redis 캐시 5종 (투어 2, 투어 상세 1, 날씨 2) → Serializer를 DTO별로 분리해 타입 안정성 확보. + - Weather cache는 12시간마다 `@Scheduled`로 비움, Tour cache는 TTL 12시간. +- **Token 관리는 Redis**: Refresh Token은 `refreshToken:{userId}` 키로 저장, Access Token은 로그아웃 시 value=logout으로 블랙리스트 처리. +- **WebSocket 보안**: CONNECT 프레임에서 Authorization 헤더 필수, 실패 시 `AuthenticationCredentialsNotFoundException` 던짐. +- **빌드 파이프라인**: `com.github.gmazzo.buildconfig`로 정적 YAML → Kotlin 상수 생성, ktlint로 브랜치 진입 전 스타일 체크. +- **Dev Experience**: `DevConfig`가 서버 부팅 시 Swagger/H2/Actuator URL, 필수 환경변수 상태를 콘솔에 안내. + +--- +## 5. API Surface (대표 엔드포인트) +| 도메인 | HTTP | 경로 | 설명 | +|--------|------|------|------| +| Auth | `POST` | `/api/auth/role` | 최초 로그인 사용자의 역할 선택 + Access Token 발급 | +| Auth | `POST` | `/api/auth/refresh` | Refresh Cookie 기반 Access Token 재발급 | +| User | `GET` | `/api/users/me` | 내 프로필 조회/수정/탈퇴 | +| AI Chat | `POST` | `/api/aichat/sessions/{id}/messages` | 사용자 메시지 저장 + Spring AI 응답 생성 | +| Tour | `POST` | `/api/aichat/sessions` | AI 채팅방 생성 (초기 제목 자동 생성) | +| UserChat | `POST` | `/api/userchat/rooms/start` | 게스트-가이드 1:1 채팅방 생성 (중복 시 기존 방 재사용) | +| Rate | `PUT` | `/api/rate/guides/{id}` | 가이드 평가 생성/수정 | + +> 전체 스펙은 `docs/api-specification.yaml`과 Swagger UI (`/swagger-ui.html`)에서 확인할 수 있습니다. + +--- +## 6. Local Setup & Developer Workflow +1. `.env.example` 복사 후 OpenRouter/Weather/Tour API 키, OAuth 클라이언트 ID를 채움. +2. (선택) `docker run -d -p 6379:6379 redis:alpine`으로 Redis 실행. +3. `./setup-git-hooks.sh` 또는 `setup-git-hooks.bat`로 ktlint 프리훅 설치. +4. `./gradlew bootRun` → Dev profile이 기본, H2 + Swagger + STOMP endpoint가 즉시 활성화. +5. `./gradlew ktlintCheck` / `ktlintFormat`으로 스타일 점검, `./gradlew build`로 통합 빌드. +6. Prod profile 배포 시 `SPRING_PROFILES_ACTIVE=prod` 설정 → JWT 필터 활성화, session stateless, OAuth 로그인 성공 시 refresh 쿠키 발급. + +## 7. Observability & Quality +- `logging.level.com.back=DEBUG`, Hibernate SQL/바인딩 로그까지 노출해 API 호출→DB 쿼리 흐름 디버깅. +- `Actuator` 기본 엔드포인트(health/info/metrics/env/beans)를 노출해 인프라 상태 확인. +- `DevConfig` 콘솔 배너로 개발 URL/환경변수/Redis 지침 안내. +- 향후 과제: 통합 테스트 케이스 보강, 메트릭 기반 알림, Redis 캐시 히트율 모니터링. + +--- +## 8. Next Steps & Opportunities +- **AI 경험 고도화**: 여행 추천 결과를 세션 메시지에 요약/카테고리화, 사용자 행동 기반 프롬프트 튜닝. +- **데이터 품질**: 관광/날씨 API 장애 대비 Circuit Breaker + Failover 데이터소스 도입, 캐시 미스 모니터링. +- **채팅 UX**: 메시지 영구 삭제/복원, 타이핑 인디케이터, 읽음 처리. +- **운영 편의**: Admin 전용 대시보드(평가/세션 로그), Redis 클러스터 환경 검증, Kubernetes 헬스체크 스크립트 추가. + +## 9. Reference Docs +- [프로젝트 구조](project-structure.md) +- [ERD](erd-diagram.md) +- [개발 규칙](DEVELOPMENT_RULES.md) +- [Redis 가이드](REDIS_GUIDE.md) +- [API 스펙](api-specification.yaml) + +--- + +> 문의 및 협업 제안: `team11@travel-guide.dev` diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt index d60a2a6..d4fd257 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt @@ -47,7 +47,6 @@ class RedisConfig { connectionFactory: RedisConnectionFactory, objectMapper: ObjectMapper, ): CacheManager { - // 각 캐시 타입별 Serializer 생성 val tourResponseSerializer = Jackson2JsonRedisSerializer(objectMapper, TourResponse::class.java) val tourDetailResponseSerializer = Jackson2JsonRedisSerializer(objectMapper, TourDetailResponse::class.java) 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 5e785fb..35dfad4 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt @@ -21,8 +21,9 @@ class SecurityConfig( ) { @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { - val isDev = environment.getProperty("spring.profiles.active")?.contains("dev") == true || - environment.activeProfiles.contains("dev") + val isDev = + environment.getProperty("spring.profiles.active")?.contains("dev") == true || + environment.activeProfiles.contains("dev") http { csrf { disable() } 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 c537f4f..72bb7a6 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 @@ -26,10 +26,8 @@ class AiChatController( private val aiChatService: AiChatService, ) { @GetMapping("/sessions") - fun getSessions( - authentication: Authentication?, - ): ResponseEntity>> { - val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 + fun getSessions(authentication: Authentication?): ResponseEntity>> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val sessions = aiChatService.getSessions(userId).map { SessionsResponse.from(it) @@ -38,10 +36,8 @@ class AiChatController( } @PostMapping("/sessions") - fun createSession( - authentication: Authentication?, - ): ResponseEntity> { - val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 + fun createSession(authentication: Authentication?): ResponseEntity> { + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val session = aiChatService.createSession(userId) val response = SessionsResponse.from(session) return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 생성되었습니다.", response)) @@ -52,7 +48,7 @@ class AiChatController( @PathVariable sessionId: Long, authentication: Authentication?, ): ResponseEntity> { - val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 aiChatService.deleteSession(sessionId, userId) return ResponseEntity.ok(ApiResponse("채팅방이 성공적으로 삭제되었습니다.")) } @@ -62,7 +58,7 @@ class AiChatController( @PathVariable sessionId: Long, authentication: Authentication?, ): ResponseEntity>> { - val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val messages = aiChatService.getSessionMessages(sessionId, userId) val response = messages.map { @@ -77,7 +73,7 @@ class AiChatController( authentication: Authentication?, @RequestBody request: AiChatRequest, ): ResponseEntity> { - val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val (userMessage, aiMessage) = aiChatService.sendMessage(sessionId, userId, request.message) val response = AiChatResponse( @@ -93,7 +89,7 @@ class AiChatController( authentication: Authentication?, @RequestBody request: UpdateSessionTitleRequest, ): ResponseEntity> { - val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 + val userId = authentication?.getUserId() ?: 1L // dev 모드: 기본 userId=1 val updatedSession = aiChatService.updateSessionTitle(sessionId, userId, request.newTitle) val response = UpdateSessionTitleResponse( diff --git a/src/main/resources/prompts.yml b/src/main/resources/prompts.yml index ad01cfe..28a29c5 100644 --- a/src/main/resources/prompts.yml +++ b/src/main/resources/prompts.yml @@ -35,6 +35,15 @@ prompts: - 조회된 관광정보를 사용자에게 친근하게 추천하세요. - 추천할 때는 장소 이름, 주소, 특징을 포함하여 3~5개 정도 제시하세요. - 이미지가 있는 경우(firstimage 필드), 반드시 마크다운 형식으로 포함하세요: ![장소의 title](firstimage URL) + - 관광정보를 제공한 후, 해당 지역에서 활동하는 가이드를 자연스럽게 제안하세요. + - 예: "이 지역에서 활동하는 여행 가이드를 찾아드릴까요?", "현지 가이드와 함께하면 더 깊이 있는 여행이 가능해요. 가이드 정보를 알아볼까요?" + + 4-1단계: 지역 가이드 검색 + - 사용자가 가이드 정보를 요청하면 findGuidesByRegion(region)을 사용하세요. + - region 파라미터에는 사용자가 이전에 조회한 지역명(예: '서울', '부산', '강남구')을 사용하세요. + - 검색된 가이드 목록을 친근하게 소개하세요. + - 각 가이드의 이름, 활동 지역, 전문 분야 등을 포함하여 제시하세요. + - 가이드가 없는 경우, "죄송합니다. 해당 지역에서 활동하는 가이드를 찾을 수 없네요. 다른 지역을 추천해드릴까요?" 라고 안내하세요. 5단계: 위치 기반 주변 검색 (특정 장소 주변) - 사용자가 이전에 조회한 장소 주변의 다른 정보를 요청하면 getLocationBasedTourInfo()를 사용하세요. @@ -52,6 +61,7 @@ prompts: - 특정 지역 날씨 요청 → getRegionalWeatherDetails(location)를 바로 사용하되, REGION_CODES_DESCRIPTION에서 해당 지역 코드를 찾아 사용 - 특정 구/군 관광정보 요청 → getAreaBasedTourInfo(contentTypeId, areaAndSigunguCode)를 바로 사용하되, CONTENT_TYPE_CODES_DESCRIPTION에서 타입 코드를 찾고, AREA_CODES_DESCRIPTION에서 지역 코드를 찾아 하이픈을 쉼표로 변환 + - 특정 지역 가이드 요청 → findGuidesByRegion(region)을 바로 사용하여 해당 지역의 가이드 목록 제공 - 특정 장소 주변 검색 → 먼저 getAreaBasedTourInfo()로 해당 장소를 찾아 mapX, mapY를 얻은 후 getLocationBasedTourInfo() 사용 - 특정 장소 상세 정보 요청 → 먼저 getAreaBasedTourInfo()로 검색 후 contentId를 얻어 getTourDetailInfo() 사용 From 680e3e3ef53cb5660f93d3bf56e4bc703914ada5 Mon Sep 17 00:00:00 2001 From: Mrbaeksang Date: Thu, 9 Oct 2025 22:02:36 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(be):=20=EC=82=AC=C3=AC=C2=9A=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/repository/UserRepository.kt | 4 ++-- .../domain/user/service/GuideService.kt | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt index 3cf1bd8..2377a36 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt @@ -16,8 +16,8 @@ interface UserRepository : JpaRepository { fun findByEmail(email: String): User? - fun findByRoleAndLocationContains( + fun findByRoleAndLocation( role: UserRole, - location: String, + location: com.back.koreaTravelGuide.domain.user.enums.Region, ): List } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt index fb43e1f..c1e77c3 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt @@ -2,6 +2,7 @@ package com.back.koreaTravelGuide.domain.guide.service import com.back.koreaTravelGuide.domain.user.dto.request.GuideUpdateRequest import com.back.koreaTravelGuide.domain.user.dto.response.GuideResponse +import com.back.koreaTravelGuide.domain.user.enums.Region import com.back.koreaTravelGuide.domain.user.enums.UserRole import com.back.koreaTravelGuide.domain.user.repository.UserRepository import org.springframework.stereotype.Service @@ -52,7 +53,14 @@ class GuideService( @Transactional(readOnly = true) fun findGuidesByRegion(region: String): List { - val guides = userRepository.findByRoleAndLocationContains(UserRole.GUIDE, region) + // String을 Region enum으로 변환 (한글 displayName 또는 영문 enum name 둘 다 지원) + val regionEnum = + Region.values().find { + it.displayName.equals(region, ignoreCase = true) || + it.name.equals(region, ignoreCase = true) + } ?: return emptyList() + + val guides = userRepository.findByRoleAndLocation(UserRole.GUIDE, regionEnum) return guides.map { GuideResponse.from(it) } } }