diff --git a/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantApi.kt b/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantApi.kt index 6dc6a03e..9b5c2d84 100644 --- a/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantApi.kt +++ b/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantApi.kt @@ -95,4 +95,11 @@ interface RestaurantApi { @RequestParam category: String?, @PageableDefault(size = 10, page = 0) pageable: Pageable, ): SliceResponse + + @Operation(summary = "이번주 업데이트된 음식점 조회") + @GetMapping("/weekly") + fun readWeeklyUpdatedRestaurants( + @Auth auth: AuthContext, + @PageableDefault(size = 10, page = 0) pageable: Pageable, + ): SliceResponse } diff --git a/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantController.kt b/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantController.kt index 287d01a7..ecb46b8c 100644 --- a/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantController.kt +++ b/src/main/kotlin/com/celuveat/restaurant/adapter/in/rest/RestaurantController.kt @@ -10,12 +10,14 @@ import com.celuveat.restaurant.application.port.`in`.ReadCelebrityRecommendResta import com.celuveat.restaurant.application.port.`in`.ReadCelebrityVisitedRestaurantUseCase import com.celuveat.restaurant.application.port.`in`.ReadInterestedRestaurantsUseCase import com.celuveat.restaurant.application.port.`in`.ReadRestaurantsUseCase +import com.celuveat.restaurant.application.port.`in`.ReadWeeklyUpdateRestaurantsUseCase import com.celuveat.restaurant.application.port.`in`.command.AddInterestedRestaurantCommand import com.celuveat.restaurant.application.port.`in`.command.DeleteInterestedRestaurantCommand import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityRecommendRestaurantsQuery import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityVisitedRestaurantQuery import com.celuveat.restaurant.application.port.`in`.query.ReadInterestedRestaurantsQuery import com.celuveat.restaurant.application.port.`in`.query.ReadRestaurantsQuery +import com.celuveat.restaurant.application.port.`in`.query.ReadWeeklyUpdateRestaurantsQuery import org.springframework.data.domain.Pageable import org.springframework.data.web.PageableDefault import org.springframework.web.bind.annotation.DeleteMapping @@ -35,6 +37,7 @@ class RestaurantController( private val readCelebrityVisitedRestaurantUseCase: ReadCelebrityVisitedRestaurantUseCase, private val readCelebrityRecommendRestaurantsUseCase: ReadCelebrityRecommendRestaurantsUseCase, private val readRestaurantsUseCase: ReadRestaurantsUseCase, + private val readWeeklyUpdateRestaurantsUseCase: ReadWeeklyUpdateRestaurantsUseCase, ) : RestaurantApi { @GetMapping("/interested") override fun getInterestedRestaurants( @@ -130,4 +133,22 @@ class RestaurantController( converter = RestaurantPreviewResponse::from, ) } + + @GetMapping("/weekly") + override fun readWeeklyUpdatedRestaurants( + auth: AuthContext, + pageable: Pageable, + ): SliceResponse { + val result = readWeeklyUpdateRestaurantsUseCase.readWeeklyUpdateRestaurants( + ReadWeeklyUpdateRestaurantsQuery( + auth.optionalMemberId(), + page = pageable.pageNumber, + size = pageable.pageSize, + ), + ) + return SliceResponse.from( + sliceResult = result, + converter = RestaurantPreviewResponse::from, + ) + } } diff --git a/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapter.kt b/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapter.kt index f52c7f23..36a2b1c7 100644 --- a/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapter.kt +++ b/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapter.kt @@ -11,6 +11,8 @@ import com.celuveat.restaurant.application.port.out.ReadRestaurantPort import com.celuveat.restaurant.domain.Restaurant import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort +import java.time.LocalDate +import java.time.LocalTime @Adapter class RestaurantPersistenceAdapter( @@ -80,6 +82,32 @@ class RestaurantPersistenceAdapter( ) } + override fun readByCreatedAtBetween( + startOfWeek: LocalDate, + endOfWeek: LocalDate, + page: Int, + size: Int, + ): SliceResult { + val pageRequest = PageRequest.of(page, size, LATEST_SORTER) + val restaurants = restaurantJpaRepository.findByCreatedAtBetween( + startOfWeek.atStartOfDay(), + endOfWeek.atTime(LocalTime.MAX), + pageRequest, + ) + val imagesByRestaurants = restaurantImageJpaRepository.findByRestaurantIn(restaurants.content) + .groupBy { it.restaurant.id } + return SliceResult.of( + contents = restaurants.content.map { + restaurantPersistenceMapper.toDomain( + it, + imagesByRestaurants[it.id]!!, + ) + }, + currentPage = page, + hasNext = restaurants.hasNext(), + ) + } + companion object { val LATEST_SORTER = Sort.by("createdAt").descending() } diff --git a/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/entity/RestaurantJpaRepository.kt b/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/entity/RestaurantJpaRepository.kt index eafabb60..78294242 100644 --- a/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/entity/RestaurantJpaRepository.kt +++ b/src/main/kotlin/com/celuveat/restaurant/adapter/out/persistence/entity/RestaurantJpaRepository.kt @@ -2,10 +2,21 @@ package com.celuveat.restaurant.adapter.out.persistence.entity import com.celuveat.common.utils.findByIdOrThrow import com.celuveat.restaurant.exception.NotFoundRestaurantException +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import java.time.LocalDateTime interface RestaurantJpaRepository : JpaRepository, CustomRestaurantRepository { override fun getById(id: Long): RestaurantJpaEntity { return findByIdOrThrow(id) { NotFoundRestaurantException } } + + @Query("SELECT r FROM RestaurantJpaEntity r WHERE r.createdAt >= :startOfWeek AND r.createdAt <= :endOfWeek ORDER BY r.createdAt DESC") + fun findByCreatedAtBetween( + startOfWeek: LocalDateTime, + endOfWeek: LocalDateTime, + pageable: Pageable, + ): Slice } diff --git a/src/main/kotlin/com/celuveat/restaurant/application/RestaurantQueryService.kt b/src/main/kotlin/com/celuveat/restaurant/application/RestaurantQueryService.kt index 8ffec2a2..968e18ee 100644 --- a/src/main/kotlin/com/celuveat/restaurant/application/RestaurantQueryService.kt +++ b/src/main/kotlin/com/celuveat/restaurant/application/RestaurantQueryService.kt @@ -6,15 +6,20 @@ import com.celuveat.restaurant.application.port.`in`.ReadCelebrityRecommendResta import com.celuveat.restaurant.application.port.`in`.ReadCelebrityVisitedRestaurantUseCase import com.celuveat.restaurant.application.port.`in`.ReadInterestedRestaurantsUseCase import com.celuveat.restaurant.application.port.`in`.ReadRestaurantsUseCase +import com.celuveat.restaurant.application.port.`in`.ReadWeeklyUpdateRestaurantsUseCase import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityRecommendRestaurantsQuery import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityVisitedRestaurantQuery import com.celuveat.restaurant.application.port.`in`.query.ReadInterestedRestaurantsQuery import com.celuveat.restaurant.application.port.`in`.query.ReadRestaurantsQuery +import com.celuveat.restaurant.application.port.`in`.query.ReadWeeklyUpdateRestaurantsQuery import com.celuveat.restaurant.application.port.`in`.result.RestaurantPreviewResult import com.celuveat.restaurant.application.port.out.ReadInterestedRestaurantPort import com.celuveat.restaurant.application.port.out.ReadRestaurantPort import com.celuveat.restaurant.domain.Restaurant import org.springframework.stereotype.Service +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters @Service class RestaurantQueryService( @@ -24,7 +29,8 @@ class RestaurantQueryService( ) : ReadInterestedRestaurantsUseCase, ReadCelebrityVisitedRestaurantUseCase, ReadCelebrityRecommendRestaurantsUseCase, - ReadRestaurantsUseCase { + ReadRestaurantsUseCase, + ReadWeeklyUpdateRestaurantsUseCase { override fun readInterestedRestaurant(query: ReadInterestedRestaurantsQuery): SliceResult { val interestedRestaurants = readInterestedRestaurantPort.readInterestedRestaurants( query.memberId, @@ -73,16 +79,6 @@ class RestaurantQueryService( } } - private fun readInterestedRestaurants( - memberId: Long?, - restaurantIds: List, - ): Set { - return memberId?.let { - readInterestedRestaurantPort.readInterestedRestaurantsByIds(it, restaurantIds) - .map { interested -> interested.restaurant }.toSet() - } ?: emptySet() - } - override fun readRestaurants(query: ReadRestaurantsQuery): SliceResult { val restaurants = readRestaurantPort.readRestaurantsByCondition( category = query.category, @@ -101,4 +97,30 @@ class RestaurantQueryService( ) } } + + override fun readWeeklyUpdateRestaurants(query: ReadWeeklyUpdateRestaurantsQuery): SliceResult { + val startOfWeek: LocalDate = query.baseDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val endOfWeek: LocalDate = query.baseDate.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) + val restaurants = readRestaurantPort.readByCreatedAtBetween(startOfWeek, endOfWeek, query.page, query.size) + val restaurantIds = restaurants.contents.map { it.id } + val celebritiesByRestaurants = readCelebritiesPort.readVisitedCelebritiesByRestaurants(restaurantIds) + val interestedRestaurants = readInterestedRestaurants(query.memberId, restaurantIds) + return restaurants.convertContent { + RestaurantPreviewResult.of( + restaurant = it, + liked = interestedRestaurants.contains(it), + visitedCelebrities = celebritiesByRestaurants[it.id]!!, + ) + } + } + + private fun readInterestedRestaurants( + memberId: Long?, + restaurantIds: List, + ): Set { + return memberId?.let { + readInterestedRestaurantPort.readInterestedRestaurantsByIds(it, restaurantIds) + .map { interested -> interested.restaurant }.toSet() + } ?: emptySet() + } } diff --git a/src/main/kotlin/com/celuveat/restaurant/application/port/in/ReadWeeklyUpdateRestaurantsUseCase.kt b/src/main/kotlin/com/celuveat/restaurant/application/port/in/ReadWeeklyUpdateRestaurantsUseCase.kt new file mode 100644 index 00000000..ee92fbf6 --- /dev/null +++ b/src/main/kotlin/com/celuveat/restaurant/application/port/in/ReadWeeklyUpdateRestaurantsUseCase.kt @@ -0,0 +1,12 @@ +package com.celuveat.restaurant.application.port.`in` + +import com.celuveat.common.application.port.`in`.result.SliceResult +import com.celuveat.restaurant.application.port.`in`.query.ReadWeeklyUpdateRestaurantsQuery +import com.celuveat.restaurant.application.port.`in`.result.RestaurantPreviewResult + +/** + * 최근 업데이트된 맛집 + */ +interface ReadWeeklyUpdateRestaurantsUseCase { + fun readWeeklyUpdateRestaurants(query: ReadWeeklyUpdateRestaurantsQuery): SliceResult +} diff --git a/src/main/kotlin/com/celuveat/restaurant/application/port/in/query/ReadWeeklyUpdateRestaurantsQuery.kt b/src/main/kotlin/com/celuveat/restaurant/application/port/in/query/ReadWeeklyUpdateRestaurantsQuery.kt new file mode 100644 index 00000000..ab79c134 --- /dev/null +++ b/src/main/kotlin/com/celuveat/restaurant/application/port/in/query/ReadWeeklyUpdateRestaurantsQuery.kt @@ -0,0 +1,12 @@ +package com.celuveat.restaurant.application.port.`in`.query + +import java.time.LocalDate + +private const val DEFAULT_LATEST_UPDATED_RESTAURANTS_SIZE = 10 + +data class ReadWeeklyUpdateRestaurantsQuery( + val memberId: Long?, + val page: Int = 0, + val size: Int = DEFAULT_LATEST_UPDATED_RESTAURANTS_SIZE, + val baseDate: LocalDate = LocalDate.now(), +) diff --git a/src/main/kotlin/com/celuveat/restaurant/application/port/out/ReadRestaurantPort.kt b/src/main/kotlin/com/celuveat/restaurant/application/port/out/ReadRestaurantPort.kt index 8ab07b38..9d9fc222 100644 --- a/src/main/kotlin/com/celuveat/restaurant/application/port/out/ReadRestaurantPort.kt +++ b/src/main/kotlin/com/celuveat/restaurant/application/port/out/ReadRestaurantPort.kt @@ -2,6 +2,7 @@ package com.celuveat.restaurant.application.port.out import com.celuveat.common.application.port.`in`.result.SliceResult import com.celuveat.restaurant.domain.Restaurant +import java.time.LocalDate interface ReadRestaurantPort { fun readVisitedRestaurantByCelebrity( @@ -20,4 +21,11 @@ interface ReadRestaurantPort { page: Int, size: Int, ): SliceResult + + fun readByCreatedAtBetween( + startOfWeek: LocalDate, + endOfWeek: LocalDate, + page: Int, + size: Int, + ): SliceResult } diff --git a/src/test/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapterTest.kt b/src/test/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapterTest.kt index 9a4df57a..af2fc567 100644 --- a/src/test/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapterTest.kt +++ b/src/test/kotlin/com/celuveat/restaurant/adapter/out/persistence/RestaurantPersistenceAdapterTest.kt @@ -17,6 +17,9 @@ import com.navercorp.fixturemonkey.kotlin.setExp import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.shouldBe +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters @PersistenceAdapterTest class RestaurantPersistenceAdapterTest( @@ -162,4 +165,57 @@ class RestaurantPersistenceAdapterTest( restaurants.contents.map { it.roadAddress } shouldContainInOrder listOf("서울", "서울") restaurants.hasNext shouldBe false } + + test("최근 업데이트된 음식점을 조회한다.") { + // given + val savedRestaurants = restaurantJpaRepository.saveAll( + listOf( + sut.giveMeBuilder() + .set(RestaurantJpaEntity::name, "1 음식점") + .sample(), + sut.giveMeBuilder() + .set(RestaurantJpaEntity::name, "2 음식점") + .sample(), + sut.giveMeBuilder() + .set(RestaurantJpaEntity::name, "3 음식점") + .sample(), + ), + ) + val savedCelebrity = celebrityJpaRepository.save(sut.giveMeOne()) + celebrityRestaurantJpaRepository.saveAll( + savedRestaurants.map { + CelebrityRestaurantJpaEntity( + celebrity = savedCelebrity, + restaurant = it, + ) + }, + ) + val baseDate = LocalDate.now() + val startOfWeek: LocalDate = baseDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val endOfWeek: LocalDate = baseDate.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) + + restaurantImageJpaRepository.saveAll( + savedRestaurants.map { + sut.giveMeBuilder() + .set(RestaurantImageJpaEntity::id, 0) + .set(RestaurantImageJpaEntity::restaurant, it) + .set(RestaurantImageJpaEntity::isThumbnail, true, 1) + .sampleList(3) + }.flatten(), + ) + + // when + val weeklyUpdatedRestaurants = restaurantPersistenceAdapter.readByCreatedAtBetween( + startOfWeek, + endOfWeek, + 0, + 3, + ) + + // then + weeklyUpdatedRestaurants.size shouldBe 3 + weeklyUpdatedRestaurants.contents.map { it.name } shouldContainInOrder listOf( + "3 음식점", "2 음식점", "1 음식점", + ) + } }) diff --git a/src/test/kotlin/com/celuveat/restaurant/application/RestaurantQueryServiceTest.kt b/src/test/kotlin/com/celuveat/restaurant/application/RestaurantQueryServiceTest.kt index f6985bbf..363082bb 100644 --- a/src/test/kotlin/com/celuveat/restaurant/application/RestaurantQueryServiceTest.kt +++ b/src/test/kotlin/com/celuveat/restaurant/application/RestaurantQueryServiceTest.kt @@ -8,6 +8,7 @@ import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityRecommen import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityVisitedRestaurantQuery import com.celuveat.restaurant.application.port.`in`.query.ReadInterestedRestaurantsQuery import com.celuveat.restaurant.application.port.`in`.query.ReadRestaurantsQuery +import com.celuveat.restaurant.application.port.`in`.query.ReadWeeklyUpdateRestaurantsQuery import com.celuveat.restaurant.application.port.out.ReadInterestedRestaurantPort import com.celuveat.restaurant.application.port.out.ReadRestaurantPort import com.celuveat.restaurant.domain.InterestedRestaurant @@ -295,6 +296,65 @@ class RestaurantQueryServiceTest : BehaviorSpec({ } } } + + Given("최근 업데이트된 음식점 조회 시") { + val restaurants = SliceResult.of( + contents = sut.giveMeBuilder().sampleList(2), + currentPage = 0, + hasNext = false, + ) + val restaurantIds = restaurants.contents.map { it.id } + val celebritiesByRestaurants = mapOf( + restaurantIds[0] to sut.giveMeBuilder() + .setExp(Celebrity::youtubeContents, generateYoutubeContents(size = 2)) + .sampleList(2), + restaurantIds[1] to sut.giveMeBuilder() + .setExp(Celebrity::youtubeContents, generateYoutubeContents(size = 1)) + .sampleList(1), + ) + When("회원이 최근 업데이트된 음식점 조회하면") { + val memberId = 1L + every { readRestaurantPort.readByCreatedAtBetween(any(), any(), any(), any()) } returns restaurants + every { readCelebritiesPort.readVisitedCelebritiesByRestaurants(restaurantIds) } returns celebritiesByRestaurants + every { + readInterestedRestaurantPort.readInterestedRestaurantsByIds( + memberId, + restaurantIds, + ) + } returns listOf( + sut.giveMeBuilder() + .setExp(InterestedRestaurant::restaurant, restaurants.contents[0]) + .sample(), + ) // 첫 번째 음식점만 관심 등록 + + val latestRestaurants = restaurantQueryService.readWeeklyUpdateRestaurants( + ReadWeeklyUpdateRestaurantsQuery(memberId, 0, 10), + ).contents + + Then("관심 등록 여부가 포함되어 응답한다") { + latestRestaurants.size shouldBe 2 + latestRestaurants[0].liked shouldBe true + latestRestaurants[0].visitedCelebrities.size shouldBe 2 + latestRestaurants[1].liked shouldBe false + latestRestaurants[1].visitedCelebrities.size shouldBe 1 + } + } + + When("비회원이 최근 업데이트된 음식점 조회하면") { + every { readRestaurantPort.readByCreatedAtBetween(any(), any(), any(), any()) } returns restaurants + every { readCelebritiesPort.readVisitedCelebritiesByRestaurants(restaurantIds) } returns celebritiesByRestaurants + + val latestRestaurants = restaurantQueryService.readWeeklyUpdateRestaurants( + ReadWeeklyUpdateRestaurantsQuery(memberId = null, page = 0, size = 10), + ).contents + + Then("관심 등록 여부는 false로 응답한다") { + latestRestaurants.size shouldBe 2 + latestRestaurants.map { it.liked } shouldBe listOf(false, false) + verify { readInterestedRestaurantPort wasNot Called } + } + } + } }) { override suspend fun afterEach( testCase: TestCase,