diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt index 23878c5..debfab1 100644 --- a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt @@ -1,7 +1,7 @@ package noweekend.client.mcp.recommend import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest -import noweekend.client.mcp.recommend.model.AiGenerateVacationResponse +import noweekend.client.mcp.recommend.model.AiVacationResponse import noweekend.client.mcp.recommend.model.TagRequest import noweekend.client.mcp.recommend.model.WeatherRequest import noweekend.core.domain.tag.TagRecommendation @@ -43,5 +43,5 @@ interface RecommendApi { consumes = [MediaType.APPLICATION_JSON_VALUE], method = [RequestMethod.POST], ) - fun generateVacation(@RequestBody request: AiGenerateVacationRequest): AiGenerateVacationResponse + fun generateVacation(@RequestBody request: AiGenerateVacationRequest): AiVacationResponse } diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt index 382326e..c72c4a7 100644 --- a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt @@ -2,7 +2,7 @@ package noweekend.client.mcp.recommend import feign.FeignException import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest -import noweekend.client.mcp.recommend.model.AiGenerateVacationResponse +import noweekend.client.mcp.recommend.model.AiVacationResponse import noweekend.client.mcp.recommend.model.TagRequest import noweekend.client.mcp.recommend.model.WeatherRequest import noweekend.client.mcp.recommend.model.toRequestType @@ -79,7 +79,7 @@ class RecommendClient( ) } - fun generateVacation(request: AiGenerateVacationRequest): AiGenerateVacationResponse? { + fun generateVacation(request: AiGenerateVacationRequest): AiVacationResponse? { return try { api.generateVacation(request) } catch (e: FeignException) { diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/AiGenerateVacation.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/AiGenerateVacation.kt index bc3f00d..5ed7a3d 100644 --- a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/AiGenerateVacation.kt +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/AiGenerateVacation.kt @@ -3,7 +3,8 @@ package noweekend.client.mcp.recommend.model import java.time.LocalDate data class AiGenerateVacationRequest( - val days: Int, + val startDate: LocalDate, + val endDate: LocalDate, val travelStyleOptionLabels: List, val chosenTravelStyleLabel: String, @@ -17,13 +18,13 @@ data class AiGenerateVacationRequest( val leisurePreferenceOptionLabels: List, val chosenLeisurePreferenceLabel: String, - val birthDate: LocalDate, val selectedTags: List, val unselectedTags: List, - val upcomingHolidays: List, ) -data class AiGenerateVacationResponse( +data class AiVacationResponse( val title: String, val content: String, + val startDate: LocalDate, + val endDate: LocalDate, ) diff --git a/noweekend-clients/client-mcp/src/main/resources/client-mcp.yml b/noweekend-clients/client-mcp/src/main/resources/client-mcp.yml index 21baeaa..1eb8a9c 100644 --- a/noweekend-clients/client-mcp/src/main/resources/client-mcp.yml +++ b/noweekend-clients/client-mcp/src/main/resources/client-mcp.yml @@ -19,8 +19,8 @@ spring.cloud.openfeign: client: config: example-api: - connectTimeout: 2100 - readTimeout: 120000 + connectTimeout: 30000 + readTimeout: 180000 loggerLevel: full compression: response: diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt index 21524bf..b49d608 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt @@ -2,11 +2,10 @@ package noweekend.core.api.controller.v1 import noweekend.core.api.controller.v1.docs.RecommendControllerDocs import noweekend.core.api.controller.v1.request.GenerateVacationRequest -import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse +import noweekend.core.api.controller.v1.response.AiVacationApiResponse import noweekend.core.api.controller.v1.response.SandwichApiResponse import noweekend.core.api.controller.v1.response.WeatherResponse import noweekend.core.api.security.annotations.CurrentUserId -import noweekend.core.domain.IconStyle import noweekend.core.domain.recommend.RecommendService import noweekend.core.domain.tag.TagRecommendations import noweekend.core.support.response.ApiResponse @@ -58,19 +57,16 @@ class RecommendController( @CurrentUserId userId: String, @RequestBody request: GenerateVacationRequest, ): ApiResponse { + recommendService.generateVacation(userId, request) return ApiResponse.success("휴가 생성 요청이 완료되었습니다.") } @GetMapping("/vacation") override fun getVacation( @CurrentUserId userId: String, - ): ApiResponse { + ): ApiResponse { return ApiResponse.success( - AiGenerateVacationApiResponse( - "title 예시", - "• Day 1 (07/07 월) - Day 3 of 5\n\n1. Morning: 집→세종문화회관, 지하철 5호선, 08:30 출발\n2. Morning activity: 세종문화회관 뮤지컬 '팬텀' 관람, 도보 이동·10분\n3. Lunch: 세븐스도어(컨템포러리 요리 전문점, 창의적 코스 요리), 종로\n4. Afternoon activity: 한강공원 수상스포츠체험, 지하철 이동·30분\n5. Dinner: 권숙수(고급 한식 파인다이닝, 전통양념갈비), 강남\n6. Night: 앰배서더 서울 - 풀만 호텔, 택시, 20:00 체크인\n\n• Day 2 (07/08 화) - Day 4 of 5\n\n1. Morning: 풀만 호텔→문래창작촌, 지하철 1호선, 09:30 출발\n2. Morning activity: 문래창작촌 예술거리 산책, 도보 이동·60분\n3. Lunch: 문래창작촌 내 카페&레스토랑(브런치 메뉴), 영등포\n4. Afternoon activity: 롯데월드 아이스링크 스케이팅, 지하철 이동·25분\n5. Dinner: 명동 한식당(비빔밥, 불고기), 명동\n6. Night: 앰배서\n\n• Day 3 (07/09 수)\n1. 오전: 집→아차산, 지하철 2호선→5호선 환승, 07:30 출발\n2. 오전 활동: 아차산 산책로 걷기 (야외 운동), 도보 이동, 약 2시간 소요\n3. 점심: 아차산통갈비탕(왕갈비탕), 아차산 근처\n4. 오후 활동: 광림아트센터 BBCH홀 '마리 퀴리' 뮤지컬 관람, 지하철 이용, 15:00 공연\n5. 저녁: 도깨비불고기 동대문본점(깨비불고기), 동대문 지역\n6. 밤: 호텔 디 아크, 택시 이용, 21:00 체크인\n\n• Day 4 (07/10 목)\n1. 오전: 호텔 디 아크→서울 중구, 지하철 2호선 이용, 09:00 출발\n2. 오전 활동: 아이스하키 원데이 클래스 체험, 택시 이동, 약 2시간 소요\n3. 점심: 에베레스트 레스토랑(탄두리치킨), 동대문 인근\n4. 오후 활동: 남산골 한옥마을 전통문화 체험, 도보 이동, 15:00~17:00\n5. 저녁: 다야(민속 국시, 칼제비), 아차산 인근\n6. 밤: 집, 지하철 및 버스 환승, 20:00 도착\n\n• Day 5 (07/11 금)\n1. 집→대학로 한예극장, 지하철 5호선, 오후 4시 출발\n2. 창작뮤지컬 「행사의 여왕」 관람, 도보 이동·10분 소요\n3. 두채 대학로(브런치 세트), 한예극장 근처\n4. 스크린 스포츠 체험, 도보 이동·15분 소요\n5. 삼 삼뚝배기(뚝배기 비빔밥), 대학로\n6. 집, 지하철 5호선, 오후 10시 30분", - IconStyle.STAR, - ), + recommendService.getVacation(userId), ) } } diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt index ca73982..37ce3c6 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt @@ -7,7 +7,7 @@ import io.swagger.v3.oas.annotations.media.ExampleObject import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.tags.Tag import noweekend.core.api.controller.v1.request.GenerateVacationRequest -import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse +import noweekend.core.api.controller.v1.response.AiVacationApiResponse import noweekend.core.api.controller.v1.response.SandwichApiResponse import noweekend.core.api.controller.v1.response.WeatherResponse import noweekend.core.api.security.annotations.CurrentUserId @@ -412,9 +412,9 @@ interface RecommendControllerDocs { @Operation( summary = "생성된 맞춤 휴가 플랜 조회", description = """ - 사용자가 요청한 맞춤 휴가 플랜(AI 기반 추천 휴가 일차별 상세 일정, 아이콘 포함)을 조회합니다. - (생성된 휴가 플랜이 없거나, 조회 불가 시 에러 반환) - """, + 사용자가 요청한 맞춤 휴가 플랜(AI 기반 추천 휴가 일차별 상세 일정, 아이콘 포함, 시작/종료일 포함)을 조회합니다. + (생성된 휴가 플랜이 없거나, 조회 불가 시 에러 반환) + """, responses = [ SwaggerApiResponse( responseCode = "200", @@ -430,8 +430,10 @@ interface RecommendControllerDocs { { "result": "SUCCESS", "data": { - "title": "5일간의 맞춤 휴가 일정 예시", - "content": "• Day 1 (07/07 월) ...\n", + "title": "가을 도심 미식+예술 여행", + "content": "## Day 1 of 10 (10/03 금)\\n\\n• 아침: 집→대학로, 지하철 2호선, 08:30 출발\\n• 오전 활동: 아르코예술극장, 뮤지컬 '세종 1446' 관람 ... (생략)", + "startDate": "2025-10-03", + "endDate": "2025-10-12", "iconStyle": "STAR" }, "error": null @@ -472,5 +474,5 @@ interface RecommendControllerDocs { ) fun getVacation( @Parameter(hidden = true) @CurrentUserId userId: String, - ): ApiResponse + ): ApiResponse } diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/response/AiGenerateVacationApiResponse.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/response/AiGenerateVacationApiResponse.kt deleted file mode 100644 index 473736f..0000000 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/response/AiGenerateVacationApiResponse.kt +++ /dev/null @@ -1,9 +0,0 @@ -package noweekend.core.api.controller.v1.response - -import noweekend.core.domain.IconStyle - -data class AiGenerateVacationApiResponse( - val title: String, - val content: String, - val iconStyle: IconStyle, -) diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/response/AiVacationApiResponse.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/response/AiVacationApiResponse.kt new file mode 100644 index 0000000..97d4c92 --- /dev/null +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/response/AiVacationApiResponse.kt @@ -0,0 +1,12 @@ +package noweekend.core.api.controller.v1.response + +import noweekend.core.domain.vacation.IconStyle +import java.time.LocalDate + +data class AiVacationApiResponse( + val title: String, + val content: String, + val startDate: LocalDate, + val endDate: LocalDate, + val iconStyle: IconStyle, +) diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/GenerateVacation.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/GenerateVacation.kt index 1a659d4..be2e5d5 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/GenerateVacation.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/GenerateVacation.kt @@ -49,10 +49,3 @@ enum class LeisurePreference( @Schema(description = "관광") TOURISM("관광"), } - -enum class IconStyle { - STAR, - TRAIN, - PLANE, - HOUSE, -} diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt index d79ff41..17f79bc 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt @@ -1,7 +1,7 @@ package noweekend.core.domain.recommend import noweekend.core.api.controller.v1.request.GenerateVacationRequest -import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse +import noweekend.core.api.controller.v1.response.AiVacationApiResponse import noweekend.core.api.controller.v1.response.SandwichApiResponse import noweekend.core.api.controller.v1.response.WeatherResponse import noweekend.core.domain.tag.TagRecommendations @@ -11,5 +11,6 @@ interface RecommendService { fun getTagRecommend(userId: String): TagRecommendations fun getTagRecommendOnlyNew(userId: String): TagRecommendations fun getSandwich(): SandwichApiResponse - fun generateVacation(userId: String, request: GenerateVacationRequest): AiGenerateVacationApiResponse + fun generateVacation(userId: String, request: GenerateVacationRequest) + fun getVacation(userId: String): AiVacationApiResponse } diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt index a46f3bb..cb8dd93 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt @@ -4,16 +4,16 @@ import noweekend.client.mcp.recommend.RecommendClient import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest import noweekend.client.mcp.recommend.model.WeatherRequest import noweekend.core.api.controller.v1.request.GenerateVacationRequest -import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse +import noweekend.core.api.controller.v1.response.AiVacationApiResponse import noweekend.core.api.controller.v1.response.SandwichApiResponse import noweekend.core.api.controller.v1.response.SandwichResponse import noweekend.core.api.controller.v1.response.WeatherResponse import noweekend.core.domain.ActivityType -import noweekend.core.domain.IconStyle import noweekend.core.domain.LeisurePreference import noweekend.core.domain.RestPreference import noweekend.core.domain.TravelStyle import noweekend.core.domain.holiday.HolidayReader +import noweekend.core.domain.sandwich.Sandwich import noweekend.core.domain.sandwich.SandwichCalculator import noweekend.core.domain.tag.RecommendType import noweekend.core.domain.tag.TagReader @@ -25,6 +25,10 @@ import noweekend.core.domain.tag.TagRecommendations import noweekend.core.domain.tag.UserTags import noweekend.core.domain.user.Location import noweekend.core.domain.user.UserReader +import noweekend.core.domain.vacation.AiVacation +import noweekend.core.domain.vacation.AiVacationReader +import noweekend.core.domain.vacation.AiVacationWriter +import noweekend.core.domain.vacation.IconStyle import noweekend.core.domain.weather.WeatherReader import noweekend.core.domain.weather.WeatherRecommendCache import noweekend.core.domain.weather.WeatherRecommendation @@ -50,6 +54,9 @@ class RecommendServiceImpl( private val tagRecommendCacheWriter: TagRecommendCacheWriter, private val weekendReader: WeekendReader, private val calculator: SandwichCalculator, + + private val aiVacationReader: AiVacationReader, + private val aiVacationWriter: AiVacationWriter, ) : RecommendService { override fun getWeatherRecommend(userId: String): WeatherResponse { @@ -247,28 +254,37 @@ class RecommendServiceImpl( return SandwichApiResponse(responses = responses) } - override fun generateVacation(userId: String, request: GenerateVacationRequest): AiGenerateVacationApiResponse { - val user = userReader.findUserById(userId) ?: throw CoreException(ErrorType.USER_NOT_FOUND_INTERNAL) - val birthDate = user.birthDate ?: throw CoreException(ErrorType.USER_BIRTH_DAY_NOT_FOUND) + override fun getVacation(userId: String): AiVacationApiResponse { + val cache = aiVacationReader.findByUserIdAndSearchDate(userId, LocalDate.now()) + if (cache != null) { + return AiVacationApiResponse( + title = cache.title, + content = cache.content, + startDate = cache.startDate, + endDate = cache.endDate, + iconStyle = cache.iconStyle, + ) + } + + throw CoreException(ErrorType.VACATION_NOT_FOUND) + } + override fun generateVacation(userId: String, request: GenerateVacationRequest) { val tags = tagReader.getUserTags(userId) val selected = (tags.selectedBasicTags + tags.selectedCustomTags).map { it.content } val unselected = (tags.unselectedBasicTags + tags.unselectedCustomTags).map { it.content } - val today = LocalDate.now() - val endDate = today.plusDays(15) - val holidaysY = holidayReader.findAllByYear(today.year) - val upcomingH = holidaysY - .filter { it.date in today..endDate } - .map { "${it.date}(${it.dayOfWeekKor.display})" } - val travelStyleLabels = TravelStyle.entries.map { it.korean } val activityTypeLabels = ActivityType.entries.map { it.korean } val restPreferenceLabels = RestPreference.entries.map { it.korean } val leisurePrefLabels = LeisurePreference.entries.map { it.korean } + val periods = getSandwichLocalDates(LocalDate.now()) + + val startDate = startDate(periods, request.days.toLong()) + val endDate = endDate(periods, request.days.toLong()) + val aiRequest = AiGenerateVacationRequest( - days = request.days, travelStyleOptionLabels = travelStyleLabels, chosenTravelStyleLabel = request.travelStyle.korean, @@ -281,21 +297,45 @@ class RecommendServiceImpl( leisurePreferenceOptionLabels = leisurePrefLabels, chosenLeisurePreferenceLabel = request.leisurePreference.korean, - birthDate = birthDate, selectedTags = selected, unselectedTags = unselected, - upcomingHolidays = upcomingH, + startDate = startDate, + endDate = endDate, ) + val iconStyle = solveIcon(request) val aiResponse = recommendClient.generateVacation(aiRequest) ?: throw CoreException(ErrorType.MCP_SERVER_INTERNAL_ERROR) - return AiGenerateVacationApiResponse( - title = aiResponse.title, - content = aiResponse.content, - iconStyle = iconStyle, + + aiVacationWriter.register( + AiVacation.register( + title = aiResponse.title, + content = aiResponse.content, + iconStyle = iconStyle, + searchDate = LocalDate.now(), + startDate = startDate, + endDate = endDate, + userId = userId, + ), ) } + fun startDate(periods: List, days: Long): LocalDate { + return if (periods.isEmpty()) { + LocalDate.now().plusMonths(1).plusDays(days) + } else { + periods[0].startDate + } + } + + fun endDate(periods: List, days: Long): LocalDate { + return if (periods.isEmpty()) { + LocalDate.now().plusDays(days) + } else { + periods[0].endDate + } + } + private fun solveIcon(request: GenerateVacationRequest): IconStyle { if (request.activityType == ActivityType.AT_HOME) { return IconStyle.HOUSE @@ -313,4 +353,21 @@ class RecommendServiceImpl( return IconStyle.STAR } + + fun getSandwichLocalDates(today: LocalDate): List { + val holidays = holidayReader.findRemainingHolidays(today).map { it.date }.toSet() + val weekends = weekendReader.getAllThisYearWeekends() + .map { it.date } + .filter { it.isAfter(today) } + .toSet() + + return calculator.recommendSandwich( + holidays = holidays, + weekends = weekends, + maxGap = 2, + minSpan = 3, + from = today, + until = Year.now().atMonth(12).atEndOfMonth(), + ) + } } diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt index 239bbc7..ec49f88 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt @@ -26,4 +26,5 @@ enum class ErrorType( INVALID_LOCATION(HttpStatus.BAD_REQUEST, ErrorCode.E400, "사용자가 한국 위치가 아니기 때문에 날씨를 추천할 수 없습니다.", LogLevel.WARN), INVALID_SCHEDULE_TAG(HttpStatus.BAD_REQUEST, ErrorCode.E400, "유효하지 않은 태그가 포함되어 있습니다.", LogLevel.WARN), INVALID_ONBOARD_STATUS(HttpStatus.BAD_REQUEST, ErrorCode.E400, "온보딩 순서에 맞게 요청하지 않은 상태입니다. 사용자 정보조회하여 어떤 값이 입력되지 않았는지 확인해주세요.", LogLevel.WARN), + VACATION_NOT_FOUND(HttpStatus.NOT_FOUND, ErrorCode.E404, "아직 휴가가 생성되지 않았습니다.", LogLevel.INFO), } diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacation.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacation.kt new file mode 100644 index 0000000..a5f1aed --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacation.kt @@ -0,0 +1,45 @@ +package noweekend.core.domain.vacation + +import noweekend.core.domain.util.IdGenerator +import java.time.LocalDate + +class AiVacation( + val id: String, + val title: String, + val content: String, + val startDate: LocalDate, + val endDate: LocalDate, + val searchDate: LocalDate, + val userId: String, + val iconStyle: IconStyle, +) { + companion object { + fun register( + title: String, + content: String, + startDate: LocalDate, + endDate: LocalDate, + searchDate: LocalDate, + userId: String, + iconStyle: IconStyle, + ): AiVacation { + return AiVacation( + id = IdGenerator.generate(), + title = title, + content = content, + startDate = startDate, + endDate = endDate, + searchDate = searchDate, + userId = userId, + iconStyle = iconStyle, + ) + } + } +} + +enum class IconStyle { + STAR, + TRAIN, + PLANE, + HOUSE, +} diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationReader.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationReader.kt new file mode 100644 index 0000000..5b5297c --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationReader.kt @@ -0,0 +1,18 @@ +package noweekend.core.domain.vacation + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Component +@Transactional(readOnly = true) +class AiVacationReader( + private val aiVacationRepository: AiVacationRepository, +) { + fun findByUserIdAndSearchDate(userId: String, searchDate: LocalDate): AiVacation? { + return aiVacationRepository.findByUserIdAndSearchDate(userId, searchDate) + } + fun findAllByUserId(userId: String): List { + return aiVacationRepository.findAllByUserId(userId) + } +} diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationRepository.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationRepository.kt new file mode 100644 index 0000000..b74c231 --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationRepository.kt @@ -0,0 +1,9 @@ +package noweekend.core.domain.vacation + +import java.time.LocalDate + +interface AiVacationRepository { + fun save(aiVacation: AiVacation) + fun findByUserIdAndSearchDate(userId: String, searchDate: LocalDate): AiVacation? + fun findAllByUserId(userId: String): List +} diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationWriter.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationWriter.kt new file mode 100644 index 0000000..6bafb72 --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/vacation/AiVacationWriter.kt @@ -0,0 +1,14 @@ +package noweekend.core.domain.vacation + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Transactional +class AiVacationWriter( + private val aiVacationRepository: AiVacationRepository, +) { + fun register(aiVacation: AiVacation) { + aiVacationRepository.save(aiVacation) + } +} diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt index 708958b..bf8a71b 100644 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt @@ -1,12 +1,10 @@ package noweekend.mcphost.controller import noweekend.mcphost.controller.request.AiGenerateVacationRequest -import noweekend.mcphost.controller.request.AiGenerateVacationResponse -import noweekend.mcphost.controller.request.SandwichRequest +import noweekend.mcphost.controller.request.AiVacationResponse import noweekend.mcphost.controller.request.Tag import noweekend.mcphost.controller.request.TagRequest import noweekend.mcphost.controller.request.WeatherRequest -import noweekend.mcphost.controller.response.BridgeVacationPeriod import noweekend.mcphost.controller.response.WeatherResponse import noweekend.mcphost.service.ChatbotService import org.springframework.http.MediaType @@ -36,13 +34,16 @@ class ChatbotController( return chatbotService.tagRecommendationOnlyNew(request) } - @PostMapping("/getSandwich") - fun getSandwich(@RequestBody request: SandwichRequest): List { - return chatbotService.getSandwich(request) - } - @PostMapping("/generate-vacation") - fun getTagOnlyNew(@RequestBody request: AiGenerateVacationRequest): AiGenerateVacationResponse { - return chatbotService.generateVacation(request) + fun generateVacation(@RequestBody request: AiGenerateVacationRequest): AiVacationResponse { + val content = chatbotService.generateVacationContent(request) + val title = chatbotService.summarizeTitle(content) + + return AiVacationResponse( + title = title.title, + content = content.content, + startDate = request.startDate, + endDate = request.endDate, + ) } } diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/Prompt.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/Prompt.kt index 8581345..12825a7 100644 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/Prompt.kt +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/Prompt.kt @@ -117,35 +117,6 @@ Return ONLY this JSON array. Never add any other text, explanation, or formattin Here is the user's tag information in JSON: """.trimIndent() - - /** - * 1단계: 생일·공휴일·주말·연차를 조합해 최대 연속 휴가 날짜(dates)를 계산 - */ - fun sandwichDatePrompt(req: AiGenerateVacationRequest): String { - return """ -You are a Vacation Date Optimizer. - -INPUT (exactly one JSON): -{"days": number, "birthDate": "YYYY-MM-DD" or null, "upcomingHolidays": ["YYYY-MM-DD", ...]} - -INSTRUCTIONS: -- Treat weekends (Sat, Sun) and upcomingHolidays as automatic non-working days. -- Use available vacation days ("days") on weekdays only to form the **longest continuous break** within targetMonth. -- If birthDate is a weekday in targetMonth, you may include it as a vacation day. -- **Output EXACTLY one valid JSON object** with key "dates", containing a sorted list of "YYYY-MM-DD". -- **Do NOT include any extra text, explanation, or debugging information.** -- If no valid continuous block exists, output: {"dates":[]} - -RULES: -- Only count weekdays toward "days". -- Continuous block may span weekends/holidays, but vacation days apply only to weekdays. -- Do not reference events outside targetMonth. - -OUTPUT: -{"dates":["YYYY-MM-DD", ...]} - """.trimIndent() - } - fun detailedPlanPrompt( req: AiGenerateVacationRequest, dates: List, @@ -153,20 +124,22 @@ OUTPUT: totalDays: Int, prevUsed: List, ): String { + // 헤더 생성 (Day 번호 & 날짜) val headers = dates.mapIndexed { i, d -> - val num = offset + i + 1 + val dayNum = offset + i + 1 val label = LocalDate.parse(d) .format(DateTimeFormatter.ofPattern("MM/dd E", Locale.KOREAN)) - "• Day $num ($label)" + "• Day $dayNum ($label)" }.joinToString("\n") + // 최근 사용 아이템 안내 val usedClause = if (prevUsed.isNotEmpty()) { - "Recently used items: ${prevUsed.joinToString(", ")}.\nDo NOT reuse in this block.\n\n" + "최근 사용된 활동/식당/숙소: ${prevUsed.joinToString(", ")}\n다음 블록에서는 절대 재사용 금지\n\n" } else { "" } - // profile JSON with Korean tags + // profile JSON (태그 부분은 변경 금지) val profileJson = objectMapper.writeValueAsString( mapOf( "travelStyle" to req.chosenTravelStyleLabel, @@ -179,45 +152,41 @@ OUTPUT: ) return """ -You are a Vacation Planning Expert. Use Perplexity to fetch real-time facts. +You are a Vacation Planning Expert. +– 여행계획을 세울 때 반드시 Perplexity를 사용하여 실시간 사실을 검색하세요. +– 절대 날씨 정보(예: 비, 눈, 기온 등)를 사용하거나 질문하지 마세요. $usedClause +HEADERS: +$headers + INPUT (JSON): {"dates":[${dates.joinToString(","){ "\"$it\"" }}],"profile":$profileJson,"totalDays":$totalDays} TASK: -Create a personalized itinerary over ${dates.size} days: -- If activityType == "집콕", propose home-based plans (e.g. cooking, movies, reading). -- Else (travel): plan actual trips with real departure→arrival chains. - * The origin of Day N must match the destination of Day N-1. - * The final day must include return to home. +- activityType이 "집콕"인 경우: 집 기반 활동 제안(요리, 영화, 독서 등). +- 그 외(여행)인 경우: 출발→도착 체인을 갖춘 실제 이동 일정 계획. + * Day N의 출발지는 Day N-1의 도착지와 일치해야 함. + * 마지막 날은 반드시 귀가로 마무리. STYLE: -- travelStyle == "계획형": use precise times, transport modes, and locations. -- travelStyle == "즉흥 자유형": allow flexible timing, optional excursions. -- Honor restPreference and leisurePreference from profile. -- Include preferredTags, avoid excludedTags. -- Prevent reuse of activities/restaurants/accommodations used within the last 2 days. -- Mention "Day X of Y" to orient days within totalDays. +- travelStyle == "계획형": 구체적 시간·교통수단·장소 포함. +- travelStyle == "즉흥 자유형": 유동적 일정·옵션 제안. +- restPreference, leisurePreference 반영. +- preferredTags 우선, excludedTags 절대 제외. +- 2일 이내 사용한 활동/식당/숙소 재사용 금지. +- "Day X of Y" 형식으로 일차 표기. FORMAT: -For each day, produce exactly 6 Korean bullet points: -1. Morning: 출발지→도착지, 교통수단, 출발시간 -2. Morning activity: 장소 및 내용, 이동 방식·소요시간 -3. Lunch: 식당명(추천메뉴), 지역 -4. Afternoon activity: 장소 및 내용, 이동 방식·소요시간 -5. Dinner: 식당명(추천메뉴), 지역 -6. Night: 숙소명 (or '집'), 교통수단, 체크인 or evening time - -REQUIREMENTS: -- Use Perplexity to verify opening hours or popularity info. -- Do not include JSON, code fences, or extra commentary. -- End output right after the last bullet. - -HEADERS: -$headers - -Now generate the itinerary. +각 Day별로 **정확히 6개**의 한국어 불릿 포인트 작성: +1. 아침: 출발지→도착지, 교통수단, 출발시간 +2. 오전 활동: 장소, 내용, 이동 방식·소요시간 +3. 점심: 식당명(추천메뉴), 지역 +4. 오후 활동: 장소, 내용, 이동 방식·소요시간 +5. 저녁: 식당명(추천메뉴), 지역 +6. 밤: 숙소명(또는 '집'), 교통수단, 체크인 정보 또는 시간 + +출력은 **불릿만**, 추가 질문·JSON·코드펜스·해설 없이 마지막 불릿 직후 종료하세요. """.trimIndent() } } diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/AiGenerateVacation.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/AiGenerateVacation.kt index d9f3ae4..b0bfc1a 100644 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/AiGenerateVacation.kt +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/AiGenerateVacation.kt @@ -3,7 +3,8 @@ package noweekend.mcphost.controller.request import java.time.LocalDate data class AiGenerateVacationRequest( - val days: Int, + val startDate: LocalDate, + val endDate: LocalDate, val travelStyleOptionLabels: List, val chosenTravelStyleLabel: String, @@ -17,13 +18,21 @@ data class AiGenerateVacationRequest( val leisurePreferenceOptionLabels: List, val chosenLeisurePreferenceLabel: String, - val birthDate: LocalDate, val selectedTags: List, val unselectedTags: List, - val upcomingHolidays: List, ) -data class AiGenerateVacationResponse( +data class AiVacationContent( + val content: String, +) + +data class AiVacationTitle( + val title: String, +) + +data class AiVacationResponse( val title: String, val content: String, + val startDate: LocalDate, + val endDate: LocalDate, ) diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt index 6cc17a7..3f1be04 100644 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt @@ -1,27 +1,21 @@ package noweekend.mcphost.service import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import noweekend.mcphost.controller.Prompt import noweekend.mcphost.controller.request.AiGenerateVacationRequest -import noweekend.mcphost.controller.request.AiGenerateVacationResponse -import noweekend.mcphost.controller.request.SandwichRequest +import noweekend.mcphost.controller.request.AiVacationContent +import noweekend.mcphost.controller.request.AiVacationTitle import noweekend.mcphost.controller.request.Tag import noweekend.mcphost.controller.request.TagRequest import noweekend.mcphost.controller.request.WeatherRequest -import noweekend.mcphost.controller.response.BridgeVacationPeriod import noweekend.mcphost.controller.response.WeatherResponse import org.slf4j.LoggerFactory import org.springframework.ai.chat.client.ChatClient -import org.springframework.ai.retry.NonTransientAiException import org.springframework.stereotype.Service -import org.springframework.web.client.RestClientResponseException import java.time.LocalDate import java.time.ZoneId -data class DateList(val dates: List) - @Service class ChatbotService( private val chatClient: ChatClient, @@ -140,7 +134,7 @@ Based on the above rules, return ONLY a valid JSON array of 3 Korean lifestyle a require(tags.all { it.content !in allOldTags }) { "추천 결과에 기존 태그가 포함됨" } return tags } - } catch (e: Exception) { + } catch (_: Exception) { if (attempt == 4) throw IllegalStateException("태그 추천을 받아올 수 없습니다.") } } @@ -149,93 +143,60 @@ Based on the above rules, return ONLY a valid JSON array of 3 Korean lifestyle a private val chunkSize = 2 - fun generateVacation(request: AiGenerateVacationRequest): AiGenerateVacationResponse { - // 1) 샌드위치 날짜 계산 (기존 로직) - val sandwichJson = objectMapper.writeValueAsString( - mapOf( - "days" to request.days, - "birthDate" to request.birthDate.toString(), - "upcomingHolidays" to request.upcomingHolidays, - ), - ) - val sandwichRaw = chatClient.prompt() - .system(prompt.sandwichDatePrompt(request)) - .user(sandwichJson) - .call() - .content() ?: error("샌드위치 휴가 날짜 생성 실패") - val cleanedSandwichJson = extractJsonObject(sandwichRaw) - val allDates = objectMapper.readValue(cleanedSandwichJson).dates + fun generateVacationContent(request: AiGenerateVacationRequest): AiVacationContent { + val allDates: List = generateSequence(request.startDate) { prev -> + if (prev < request.endDate) prev.plusDays(1) else null + }.map(LocalDate::toString).toList() - val chunkCache = mutableMapOf() val usedItems = mutableListOf() + val chunkedDates = allDates.chunked(chunkSize) + val itineraryChunks = mutableListOf() - val itineraryChunks = allDates.chunked(chunkSize).mapIndexed { idx, datesChunk -> - chunkCache[idx]?.let { return@mapIndexed it } - - val detailPrompt = prompt.detailedPlanPrompt(request, datesChunk, idx * chunkSize, allDates.size, usedItems) - val userJson = objectMapper.writeValueAsString( - mapOf( - "dates" to datesChunk, - "profile" to minimalProfileMap(request), - "totalDays" to allDates.size, - ), - ) + for ((idx, datesChunk) in chunkedDates.withIndex()) { + val maxAttempts = 10 + var attempt = 0 + var chunkContent: String? while (true) { try { + val detailPrompt = prompt.detailedPlanPrompt( + request, + datesChunk, + idx * chunkSize, + allDates.size, + usedItems, + ) + val userJson = objectMapper.writeValueAsString( + mapOf( + "dates" to datesChunk, + "profile" to minimalProfileMap(request), + "totalDays" to allDates.size, + ), + ) val resp = chatClient.prompt() .system(detailPrompt) .user(userJson) .call() - val content = resp.content() ?: error("Empty chunk") - usedItems += extractUsedItems(content) - chunkCache[idx] = content - return@mapIndexed content - } catch (e: NonTransientAiException) { - val cause = e.cause - val status = (cause as? RestClientResponseException)?.statusCode?.value() - val headers = (cause as? RestClientResponseException)?.responseHeaders - - if (status == 429) { - val retryAfterSec = headers - ?.getFirst("retry-after") - ?.toLongOrNull() - ?: 180L - - logger.warn("429 rate limit hit, waiting $retryAfterSec seconds before retry") - Thread.sleep(retryAfterSec * 1000) - continue + chunkContent = resp.content() ?: error("Empty chunk at index $idx") + usedItems += extractUsedItems(chunkContent) + break // 성공했으면 while 빠져나감 + } catch (e: Exception) { + attempt++ + if (attempt >= maxAttempts) { + throw IllegalStateException("Chunk $idx 생성 실패: ${e.message}", e) } - throw e + // 재시도 딜레이 추가 + Thread.sleep(1000) } } + itineraryChunks += chunkContent ?: "" // null이 올 일은 없음(실패면 위에서 throw) } val planRaw = itineraryChunks.joinToString("\n\n") - - val summary = chatClient.prompt() - .system( - """ - You are an expert at creating concise and catchy Korean titles for vacation plans. - Summarize core concept in 15 characters max, Korean only. - """.trimIndent(), - ) - .user( - """ - 아래는 사용자 일정입니다: - $planRaw - - 핵심 키워드 중심으로 15자 이내 제목 하나 만들어 주세요. - """.trimIndent(), - ) - .call() - .content() ?: error("제목 요약 실패") - - return AiGenerateVacationResponse(title = summary, content = planRaw) + return AiVacationContent(content = planRaw) } private fun minimalProfileMap(req: AiGenerateVacationRequest) = mapOf( - "days" to req.days, "travelStyle" to req.chosenTravelStyleLabel, "activityType" to req.chosenActivityTypeLabel, "restPreference" to req.chosenRestPreferenceLabel, @@ -253,90 +214,21 @@ Based on the above rules, return ONLY a valid JSON array of 3 Korean lifestyle a return items } - private fun printRaw(text: String) { - println("------------------------------------------------\n") - println(text) - println("------------------------------------------------\n") - } - - private fun extractJsonObject(raw: String): String { - val start = raw.indexOfFirst { it == '{' } - val end = raw.lastIndexOf('}') - if (start == -1 || end == -1 || end <= start) { - error("응답에서 유효한 JSON을 찾을 수 없습니다: $raw") - } - return raw.substring(start, end + 1) - } - - fun getSandwich(request: SandwichRequest): List { - request.holidays.forEach { holiday -> println(holiday) } - request.weekends.forEach { weekend -> println(weekend) } - + fun summarizeTitle(content: AiVacationContent): AiVacationTitle { val systemPrompt = """ -Given the input data below, find all possible "bridge vacation" periods for the rest of the year. - -INPUT: -- Public holidays: [${request.holidays.joinToString(", ")}] -- Weekends: [${request.weekends.joinToString(", ")}] - -RULES: -1. A "bridge vacation" is a period that connects public holidays and weekends, allowing for up to 1 or 2 weekdays ("gaps") between them, if those weekdays can be replaced with annual leave. -2. If more than 2 consecutive weekdays (gaps) occur, **end the current vacation block before these weekdays begin**, and start a new block from the next holiday or weekend. -3. If after using up the allowed gaps (1 or 2 consecutive weekdays), another holiday or weekend immediately follows, **continue the same block**. -4. If there are 3 or more consecutive weekdays (not holidays or weekends), **break the block** and start a new vacation block after the next holiday/weekend. -5. For each bridge vacation block, output ONLY: - - "startDate": yyyy-MM-dd (first date of the period) - - "endDate": yyyy-MM-dd (last date of the period) -6. Only output blocks that are at least 3 days long (inclusive). -7. DO NOT return overlapping or duplicate blocks. -8. Output MUST be a valid JSON array of objects, each with "startDate" and "endDate". - - No totalDays, no extra fields. - - NO markdown, code block, explanation, or any other text—**JSON array ONLY**. -9. If the output includes anything other than the JSON array, it is INVALID. - -EXAMPLE (must follow this format exactly): - -[ - { "startDate": "2025-10-02", "endDate": "2025-10-09" }, - { "startDate": "2025-12-24", "endDate": "2025-12-28" } -] - -***Return ONLY a JSON array as above. No explanation, markdown, or extra text.*** + 당신은 여행 일정을 한눈에 파악할 수 있는 전문가입니다. + 아래 여행 일정을 최대 15글자로 요약해 주세요. + 장소/테마/특징을 간결하게 써주세요. 불필요한 설명, 감탄사, 접두사 빼고 핵심만! """.trimIndent() + val userPrompt = content.content - val objectMapper = jacksonObjectMapper().findAndRegisterModules() - - var lastException: Throwable? = null - repeat(30) { attempt -> - try { - val rawResponse = chatClient.prompt() - .system(systemPrompt) - .user("It's Order") - .call() - .content() ?: throw IllegalStateException("No response from MCP host") - - println("rawResponse = $rawResponse") - - var cleaned = rawResponse.trim() - if (cleaned.startsWith("```json")) cleaned = cleaned.removePrefix("```json").trim() - if (cleaned.startsWith("```")) cleaned = cleaned.removePrefix("```").trim() - if (cleaned.endsWith("```")) cleaned = cleaned.removeSuffix("```").trim() - - val arrStart = cleaned.indexOfFirst { it == '[' } - val arrEnd = cleaned.lastIndexOf(']') - if (arrStart != -1 && arrEnd != -1 && arrEnd > arrStart) { - cleaned = cleaned.substring(arrStart, arrEnd + 1) - } - - println("cleaned = $cleaned") - - // 바로 파싱 (실패시 예외 발생) - return objectMapper.readValue(cleaned) - } catch (e: Throwable) { - lastException = e - println("getSandwich retry ${attempt + 1}/30: ${e.message}") - } - } - throw IllegalStateException("Failed to get valid bridge vacation periods after 30 attempts", lastException) + val resp = chatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .call() + val summary = resp.content() ?: error("요약 실패") + // 혹시 15글자 넘는 경우 substring(0, 15) 등 처리 + val response = if (summary.length > 15) summary.take(15) else summary + return AiVacationTitle(response) } } diff --git a/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationCoreRepository.kt b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationCoreRepository.kt new file mode 100644 index 0000000..f150af1 --- /dev/null +++ b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationCoreRepository.kt @@ -0,0 +1,24 @@ +package noweekend.storage.db.core.vacation + +import noweekend.core.domain.vacation.AiVacation +import noweekend.core.domain.vacation.AiVacationRepository +import org.springframework.stereotype.Repository +import java.time.LocalDate + +@Repository +class AiVacationCoreRepository( + private val aiVacationJpaRepository: AiVacationJpaRepository, +) : AiVacationRepository { + + override fun save(aiVacation: AiVacation) { + aiVacationJpaRepository.save(aiVacation.toEntity()) + } + + override fun findByUserIdAndSearchDate(userId: String, searchDate: LocalDate): AiVacation? { + return aiVacationJpaRepository.findByUserIdAndSearchDate(userId, searchDate)?.toDomain() + } + + override fun findAllByUserId(userId: String): List { + return aiVacationJpaRepository.findAllByUserId(userId).map { it.toDomain() } + } +} diff --git a/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationEntity.kt b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationEntity.kt new file mode 100644 index 0000000..d4df1b3 --- /dev/null +++ b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationEntity.kt @@ -0,0 +1,73 @@ +package noweekend.storage.db.core.vacation + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import noweekend.core.domain.vacation.AiVacation +import noweekend.core.domain.vacation.IconStyle +import java.time.LocalDate + +@Entity +@Table( + name = "ai_vacation", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_ai_vacation_user_searchdate", + columnNames = ["user_id", "search_date"], + ), + ], +) +class AiVacationEntity( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "title", nullable = false) + val title: String, + + @Column(name = "content", columnDefinition = "TEXT") + val content: String, + + @Column(name = "start_date") + val startDate: LocalDate, + + @Column(name = "end_date") + val endDate: LocalDate, + + @Column(name = "search_date") + val searchDate: LocalDate, + + @Column(name = "user_id") + val userId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "icon_style") + val iconStyle: IconStyle, +) + +// Domain <-> Entity 변환 +fun AiVacation.toEntity() = AiVacationEntity( + id = this.id, + title = this.title, + content = this.content, + startDate = this.startDate, + endDate = this.endDate, + searchDate = this.searchDate, + userId = this.userId, + iconStyle = this.iconStyle, +) + +fun AiVacationEntity.toDomain() = AiVacation( + id = this.id, + title = this.title, + content = this.content, + startDate = this.startDate, + endDate = this.endDate, + searchDate = this.searchDate, + userId = this.userId, + iconStyle = this.iconStyle, +) diff --git a/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationJpaRepository.kt b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationJpaRepository.kt new file mode 100644 index 0000000..839e692 --- /dev/null +++ b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/vacation/AiVacationJpaRepository.kt @@ -0,0 +1,9 @@ +package noweekend.storage.db.core.vacation + +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate + +interface AiVacationJpaRepository : JpaRepository { + fun findAllByUserId(userId: String): List + fun findByUserIdAndSearchDate(userId: String, searchDate: LocalDate): AiVacationEntity? +}