Skip to content

Commit 7d3a3b1

Browse files
authored
feat: Implement real sandwich recommendation (#67)
2 parents c1728f0 + 9ffaa72 commit 7d3a3b1

File tree

31 files changed

+870
-236
lines changed

31 files changed

+870
-236
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package noweekend.client.mcp
2+
3+
import java.lang.RuntimeException
4+
5+
class McpNotRespondingException() : RuntimeException()

noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package noweekend.client.mcp.recommend
22

3+
import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest
4+
import noweekend.client.mcp.recommend.model.AiGenerateVacationResponse
5+
import noweekend.client.mcp.recommend.model.BridgeVacationPeriod
36
import noweekend.client.mcp.recommend.model.SandwichRequest
4-
import noweekend.client.mcp.recommend.model.SandwichResponse
57
import noweekend.client.mcp.recommend.model.TagRequest
68
import noweekend.client.mcp.recommend.model.WeatherRequest
79
import noweekend.core.domain.tag.TagRecommendation
@@ -43,5 +45,12 @@ interface RecommendApi {
4345
consumes = [MediaType.APPLICATION_JSON_VALUE],
4446
method = [RequestMethod.POST],
4547
)
46-
fun getSandwich(@RequestBody request: SandwichRequest): SandwichResponse
48+
fun getSandwich(@RequestBody request: SandwichRequest): List<BridgeVacationPeriod>
49+
50+
@RequestMapping(
51+
value = ["/generate-vacation"],
52+
consumes = [MediaType.APPLICATION_JSON_VALUE],
53+
method = [RequestMethod.POST],
54+
)
55+
fun generateVacation(@RequestBody request: AiGenerateVacationRequest): AiGenerateVacationResponse
4756
}

noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package noweekend.client.mcp.recommend
22

33
import feign.FeignException
4+
import noweekend.client.mcp.McpNotRespondingException
5+
import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest
6+
import noweekend.client.mcp.recommend.model.AiGenerateVacationResponse
7+
import noweekend.client.mcp.recommend.model.BridgeVacationPeriod
48
import noweekend.client.mcp.recommend.model.SandwichRequest
5-
import noweekend.client.mcp.recommend.model.SandwichResponse
69
import noweekend.client.mcp.recommend.model.TagRequest
710
import noweekend.client.mcp.recommend.model.WeatherRequest
811
import noweekend.client.mcp.recommend.model.toRequestType
@@ -79,11 +82,23 @@ class RecommendClient(
7982
)
8083
}
8184

82-
fun getSandwich(request: SandwichRequest): SandwichResponse? {
85+
fun getSandwich(request: SandwichRequest): List<BridgeVacationPeriod> {
8386
return try {
8487
api.getSandwich(request)
8588
} catch (e: FeignException) {
86-
log.warn("[getSandwich] FeignException, empty 반환. msg=${e.message}")
89+
log.warn("[getSandwich] FeignException occurred. msg=${e.message}")
90+
throw McpNotRespondingException()
91+
} catch (e: Exception) {
92+
log.error("[getSandwich] Unexpected exception occurred.", e)
93+
throw McpNotRespondingException()
94+
}
95+
}
96+
97+
fun generateVacation(request: AiGenerateVacationRequest): AiGenerateVacationResponse? {
98+
return try {
99+
api.generateVacation(request)
100+
} catch (e: FeignException) {
101+
log.warn("[generateVacation] FeignException, empty 반환. msg=${e.message}")
87102
return null
88103
} catch (e: Exception) {
89104
log.error("[getSandwich] 예기치 못한 예외, empty 반환.", e)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package noweekend.client.mcp.recommend.model
2+
3+
import java.time.LocalDate
4+
5+
data class AiGenerateVacationRequest(
6+
val days: Int,
7+
8+
val travelStyleOptionLabels: List<String>,
9+
val chosenTravelStyleLabel: String,
10+
11+
val activityTypeOptionLabels: List<String>,
12+
val chosenActivityTypeLabel: String,
13+
14+
val restPreferenceOptionLabels: List<String>,
15+
val chosenRestPreferenceLabel: String,
16+
17+
val leisurePreferenceOptionLabels: List<String>,
18+
val chosenLeisurePreferenceLabel: String,
19+
20+
val birthDate: LocalDate,
21+
val selectedTags: List<String>,
22+
val unselectedTags: List<String>,
23+
val upcomingHolidays: List<String>,
24+
)
25+
26+
data class AiGenerateVacationResponse(
27+
val title: String,
28+
val content: String,
29+
)

noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/Sandwich.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,22 @@ import java.time.LocalDate
55
data class SandwichRequest(
66
val birthDay: LocalDate,
77
val holidays: List<LocalDate>,
8+
val remainingAnnualLeave: Int,
9+
val weekends: List<LocalDate>,
10+
)
11+
12+
data class BridgeVacationPeriod(
13+
val startDate: LocalDate,
14+
val endDate: LocalDate,
815
)
916

1017
data class SandwichResponse(
1118
val startDate: LocalDate,
1219
val endDate: LocalDate,
20+
val useAnnualLeave: Int,
21+
val totalVacationDays: Int,
22+
)
23+
24+
data class SandwichApiResponse(
25+
val responses: List<SandwichResponse>,
1326
)

noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package noweekend.core.api.controller.v1
22

3+
import noweekend.client.mcp.recommend.model.SandwichApiResponse
34
import noweekend.client.mcp.recommend.model.SandwichResponse
45
import noweekend.core.api.controller.v1.docs.RecommendControllerDocs
56
import noweekend.core.api.controller.v1.request.GenerateVacationRequest
@@ -51,13 +52,25 @@ class RecommendController(
5152
@GetMapping("/sandwich")
5253
override fun getSandwich(
5354
@CurrentUserId userId: String,
54-
): ApiResponse<SandwichResponse> {
55-
val mockData = SandwichResponse(
56-
startDate = LocalDate.now(),
57-
endDate = LocalDate.now().plusDays(3),
55+
): ApiResponse<SandwichApiResponse> {
56+
// return ApiResponse.success(recommendService.getSandwich(userId))
57+
val mockResponse = SandwichApiResponse(
58+
responses = listOf(
59+
SandwichResponse(
60+
startDate = LocalDate.of(2025, 8, 14),
61+
endDate = LocalDate.of(2025, 8, 16),
62+
useAnnualLeave = 1,
63+
totalVacationDays = 3,
64+
),
65+
SandwichResponse(
66+
startDate = LocalDate.of(2025, 9, 11),
67+
endDate = LocalDate.of(2025, 9, 15),
68+
useAnnualLeave = 2,
69+
totalVacationDays = 5,
70+
),
71+
),
5872
)
59-
return ApiResponse.success(mockData)
60-
// return ApiResponse.success(recommendService.getSandwich(userId))
73+
return ApiResponse.success(mockResponse)
6174
}
6275

6376
@PostMapping("/generate-vacation")

noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt

Lines changed: 22 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Content
66
import io.swagger.v3.oas.annotations.media.ExampleObject
77
import io.swagger.v3.oas.annotations.media.Schema
88
import io.swagger.v3.oas.annotations.tags.Tag
9-
import noweekend.client.mcp.recommend.model.SandwichResponse
9+
import noweekend.client.mcp.recommend.model.SandwichApiResponse
1010
import noweekend.core.api.controller.v1.request.GenerateVacationRequest
1111
import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse
1212
import noweekend.core.api.controller.v1.response.WeatherResponse
@@ -249,15 +249,15 @@ interface RecommendControllerDocs {
249249
): ApiResponse<TagRecommendations>
250250

251251
@Operation(
252-
summary = "샌드위치 연휴 추천",
252+
summary = "유저의 남은 연차와 올해 남은 공휴일/주말로 샌드위치 휴가(bridge vacation) 추천",
253253
description = """
254-
사용자 생일 및 공휴일 정보를 기반으로
255-
샌드위치 연차(연휴 시작일·종료일) 기간을 추천합니다.
254+
유저의 남은 연차, 올해 남은 공휴일/주말을 바탕으로, 연속으로 쉴 수 있는 휴가(샌드위치 휴가) 구간을 추천합니다.
255+
각 휴가 구간별 실제 사용 연차 일수와 전체 휴가 일수도 반환됩니다.
256256
""",
257257
responses = [
258258
SwaggerApiResponse(
259259
responseCode = "200",
260-
description = "샌드위치 연차 기간 반환 성공",
260+
description = "샌드위치 휴가 추천 성공",
261261
content = [
262262
Content(
263263
mediaType = "application/json",
@@ -269,8 +269,20 @@ interface RecommendControllerDocs {
269269
{
270270
"result": "SUCCESS",
271271
"data": {
272-
"startDate": "2025-07-14",
273-
"endDate": "2025-07-16"
272+
"responses": [
273+
{
274+
"startDate": "2025-10-02",
275+
"endDate": "2025-10-09",
276+
"useAnnualLeave": 1,
277+
"totalVacationDays": 8
278+
},
279+
{
280+
"startDate": "2025-12-24",
281+
"endDate": "2025-12-28",
282+
"useAnnualLeave": 1,
283+
"totalVacationDays": 5
284+
}
285+
]
274286
},
275287
"error": null
276288
}
@@ -289,66 +301,14 @@ interface RecommendControllerDocs {
289301
schema = Schema(implementation = ApiResponse::class),
290302
examples = [
291303
ExampleObject(
292-
name = "잘못된 요청 예시",
304+
name = "에러 예시",
293305
value = """
294306
{
295307
"result": "ERROR",
296308
"data": null,
297309
"error": {
298310
"code": "INVALID_PARAMETER",
299-
"message": "잘못된 요청입니다.",
300-
"data": {}
301-
}
302-
}
303-
""",
304-
),
305-
],
306-
),
307-
],
308-
),
309-
SwaggerApiResponse(
310-
responseCode = "400",
311-
description = "생일 정보 없음",
312-
content = [
313-
Content(
314-
mediaType = "application/json",
315-
schema = Schema(implementation = ApiResponse::class),
316-
examples = [
317-
ExampleObject(
318-
name = "생일 정보 없음 에러 예시",
319-
value = """
320-
{
321-
"result": "ERROR",
322-
"data": null,
323-
"error": {
324-
"code": "USER_BIRTH_DAY_NOT_FOUND",
325-
"message": "사용자가 생일을 갖고있지 않습니다. 생일 먼저 추가해주세요.",
326-
"data": {}
327-
}
328-
}
329-
""",
330-
),
331-
],
332-
),
333-
],
334-
),
335-
SwaggerApiResponse(
336-
responseCode = "504",
337-
description = "MCP 추천 서버 무응답",
338-
content = [
339-
Content(
340-
mediaType = "application/json",
341-
schema = Schema(implementation = ApiResponse::class),
342-
examples = [
343-
ExampleObject(
344-
name = "MCP 서버 타임아웃 에러 예시",
345-
value = """
346-
{
347-
"result": "ERROR",
348-
"data": null,
349-
"error": {
350-
"code": "MCP_SERVER_SANDWICH_ERROR",
351-
"message": "MCP 추천 서버의 응답이 없습니다. 잠시 후 다시 시도해주세요.",
311+
"message": "사용자 정보를 찾을 수 없습니다.",
352312
"data": {}
353313
}
354314
}
@@ -362,7 +322,7 @@ interface RecommendControllerDocs {
362322
)
363323
fun getSandwich(
364324
@Parameter(hidden = true) @CurrentUserId userId: String,
365-
): ApiResponse<SandwichResponse>
325+
): ApiResponse<SandwichApiResponse>
366326

367327
@Operation(
368328
summary = "AI 기반 여행 일정 생성",

noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/holiday/HolidayServiceImpl.kt

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,22 @@ import java.time.LocalDate
1010
@Service
1111
class HolidayServiceImpl(
1212
private val holidayClient: HolidayClient,
13-
private val holidayRepository: HolidayRepository,
1413
private val holidayWriter: HolidayWriter,
14+
private val holidayReader: HolidayReader,
1515
) : HolidayService {
1616

1717
override fun getMonthHolidays(
1818
year: Int,
1919
month: Int,
2020
): List<HolidayResponse> {
21-
return holidayRepository.findAllByYear(year)
22-
.asSequence()
23-
.filter { it.month == month }
24-
.sortedBy { it.day }
21+
return holidayReader.findMonthHolidays(year, month)
2522
.map { HolidayResponse.from(it) }
26-
.toList()
2723
}
2824

2925
override fun getRemainingHolidays(): List<HolidayResponse> {
3026
val today = LocalDate.now()
31-
val year = today.year
32-
33-
return holidayRepository.findAllByYear(year)
34-
.asSequence()
35-
.map { it to LocalDate.of(it.year, it.month, it.day) }
36-
.filter { (_, date) -> date.isAfter(today) || date.isEqual(today) }
37-
.sortedBy { (_, date) -> date }
38-
.map { (entity, _) -> HolidayResponse.from(entity) }
39-
.toList()
27+
return holidayReader.findRemainingHolidays(today)
28+
.map { HolidayResponse.from(it) }
4029
}
4130

4231
override fun updateHolidays(year: Int) {
@@ -45,15 +34,12 @@ class HolidayServiceImpl(
4534
}
4635

4736
private fun syncHolidays(year: Int): List<Holiday> {
48-
// 1) DB에 이미 저장된 공휴일 불러오기
49-
val saved = holidayRepository.findAllByYear(year)
37+
val saved = holidayReader.findAllByYear(year)
5038

51-
// 2) 기존에 저장된 키 집합 생성 ((month, day, content) + dayOfWeekKor)
5239
val existing = saved
5340
.map { Triple(it.month, it.day, it.content) to it.dayOfWeekKor }
5441
.toSet()
5542

56-
// 3) 외부 API 호출 → Domain 객체 리스트
5743
val fetched = (1..12).flatMap { month ->
5844
holidayClient
5945
.getHolidays(HolidayRequest(year, month))
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package noweekend.core.domain.recommend
22

3-
import noweekend.client.mcp.recommend.model.SandwichResponse
3+
import noweekend.client.mcp.recommend.model.SandwichApiResponse
4+
import noweekend.core.api.controller.v1.request.GenerateVacationRequest
5+
import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse
46
import noweekend.core.api.controller.v1.response.WeatherResponse
57
import noweekend.core.domain.tag.TagRecommendations
68

79
interface RecommendService {
810
fun getWeatherRecommend(userId: String): WeatherResponse
911
fun getTagRecommend(userId: String): TagRecommendations
1012
fun getTagRecommendOnlyNew(userId: String): TagRecommendations
11-
fun getSandwich(userId: String): SandwichResponse
13+
fun getSandwich(userId: String): SandwichApiResponse
14+
fun generateVacation(userId: String, request: GenerateVacationRequest): AiGenerateVacationApiResponse
1215
}

0 commit comments

Comments
 (0)