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,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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,7 +79,7 @@ class RecommendClient(
)
}

fun generateVacation(request: AiGenerateVacationRequest): AiGenerateVacationResponse? {
fun generateVacation(request: AiGenerateVacationRequest): AiVacationResponse? {
return try {
api.generateVacation(request)
} catch (e: FeignException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
val chosenTravelStyleLabel: String,
Expand All @@ -17,13 +18,13 @@ data class AiGenerateVacationRequest(
val leisurePreferenceOptionLabels: List<String>,
val chosenLeisurePreferenceLabel: String,

val birthDate: LocalDate,
val selectedTags: List<String>,
val unselectedTags: List<String>,
val upcomingHolidays: List<String>,
)

data class AiGenerateVacationResponse(
data class AiVacationResponse(
val title: String,
val content: String,
val startDate: LocalDate,
val endDate: LocalDate,
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ spring.cloud.openfeign:
client:
config:
example-api:
connectTimeout: 2100
readTimeout: 120000
connectTimeout: 30000
readTimeout: 180000
loggerLevel: full
compression:
response:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,19 +57,16 @@ class RecommendController(
@CurrentUserId userId: String,
@RequestBody request: GenerateVacationRequest,
): ApiResponse<String> {
recommendService.generateVacation(userId, request)
return ApiResponse.success("휴가 생성 요청이 완료되었습니다.")
}

@GetMapping("/vacation")
override fun getVacation(
@CurrentUserId userId: String,
): ApiResponse<AiGenerateVacationApiResponse> {
): ApiResponse<AiVacationApiResponse> {
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),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -412,9 +412,9 @@ interface RecommendControllerDocs {
@Operation(
summary = "생성된 맞춤 휴가 플랜 조회",
description = """
사용자가 요청한 맞춤 휴가 플랜(AI 기반 추천 휴가 일차별 상세 일정, 아이콘 포함)을 조회합니다.
(생성된 휴가 플랜이 없거나, 조회 불가 시 에러 반환)
""",
사용자가 요청한 맞춤 휴가 플랜(AI 기반 추천 휴가 일차별 상세 일정, 아이콘 포함, 시작/종료일 포함)을 조회합니다.
(생성된 휴가 플랜이 없거나, 조회 불가 시 에러 반환)
""",
responses = [
SwaggerApiResponse(
responseCode = "200",
Expand All @@ -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
Expand Down Expand Up @@ -472,5 +474,5 @@ interface RecommendControllerDocs {
)
fun getVacation(
@Parameter(hidden = true) @CurrentUserId userId: String,
): ApiResponse<AiGenerateVacationApiResponse>
): ApiResponse<AiVacationApiResponse>
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,3 @@ enum class LeisurePreference(
@Schema(description = "관광")
TOURISM("관광"),
}

enum class IconStyle {
STAR,
TRAIN,
PLANE,
HOUSE,
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,

Expand All @@ -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<Sandwich>, days: Long): LocalDate {
return if (periods.isEmpty()) {
LocalDate.now().plusMonths(1).plusDays(days)
} else {
periods[0].startDate
}
}

fun endDate(periods: List<Sandwich>, 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
Expand All @@ -313,4 +353,21 @@ class RecommendServiceImpl(

return IconStyle.STAR
}

fun getSandwichLocalDates(today: LocalDate): List<Sandwich> {
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(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Loading