From 9837b847e6189e020ae07a982e70cab380f303e4 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 11 Dec 2025 10:38:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[#20]=20Feat:=20=EC=88=99=EC=86=8C=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/tripPlan/entity/Accommodation.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/com/example/triptalk/domain/tripPlan/entity/Accommodation.java diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/entity/Accommodation.java b/src/main/java/com/example/triptalk/domain/tripPlan/entity/Accommodation.java new file mode 100644 index 0000000..53e0c03 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/entity/Accommodation.java @@ -0,0 +1,36 @@ +package com.example.triptalk.domain.tripPlan.entity; + +import com.example.triptalk.global.apiPayload.code.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class Accommodation extends BaseEntity { + + @Column(length = 200, nullable = false) + private String hotelName; // 호텔 이름 + + @Column(length = 50, nullable = false) + private String cityName; // 도시 한국어명 (예: 서울, 도쿄) + + @Column(nullable = false) + private Integer pricePerNight; // 1박 가격 (원화) + + @Column(nullable = false) + private LocalDate checkInDate; // 체크인 날짜 + + @Column(nullable = false) + private LocalDate checkOutDate; // 체크아웃 날짜 + + @Column(length = 255, nullable = false) + private String imageUrl; // 도시 대표 이미지 URL + +} + From d8275a093442c4e7604a3a24cc31bfc431cc93ad Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 11 Dec 2025 10:38:36 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[#20]=20Config:=20=EC=88=99=EC=86=8C=20API?= =?UTF-8?q?=20=EB=B9=84=EC=9D=B8=EC=A6=9D=20=EC=A0=91=EA=B7=BC=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/triptalk/global/config/SecurityConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/triptalk/global/config/SecurityConfig.java b/src/main/java/com/example/triptalk/global/config/SecurityConfig.java index e1fef2d..50046cc 100644 --- a/src/main/java/com/example/triptalk/global/config/SecurityConfig.java +++ b/src/main/java/com/example/triptalk/global/config/SecurityConfig.java @@ -41,8 +41,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/auth/**").permitAll() // 여행지 조회는 비회원도 가능 .requestMatchers("/api/trip-places/**").permitAll() - // 항공권 검색은 비회원도 가능 + // 항공권 조회는 비회원도 가능 .requestMatchers("/api/flights/**").permitAll() + // 호텔 조회는 비회원도 가능 + .requestMatchers("/api/accommodations/**").permitAll() // Swagger UI 접근 허용 .requestMatchers( "/swagger-ui/**", From 69423b538c0aec16a895925fd1c41b85ddf71091 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 11 Dec 2025 10:38:59 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[#20]=20Feat:=20Accommodation=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=EC=A1=B0=ED=9A=8C=20Repositor?= =?UTF-8?q?y=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/AccommodationRepository.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/com/example/triptalk/domain/tripPlan/repository/AccommodationRepository.java diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/repository/AccommodationRepository.java b/src/main/java/com/example/triptalk/domain/tripPlan/repository/AccommodationRepository.java new file mode 100644 index 0000000..0c87c17 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/repository/AccommodationRepository.java @@ -0,0 +1,26 @@ +package com.example.triptalk.domain.tripPlan.repository; + +import com.example.triptalk.domain.tripPlan.entity.Accommodation; +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 org.springframework.data.repository.query.Param; + +public interface AccommodationRepository extends JpaRepository { + + /** + * 커서 기반 숙소 목록 조회 (ID 내림차순) + * @param cursorId 커서 ID (null이면 처음부터 조회) + * @param pageable 페이징 정보 + * @return Slice + */ + @Query("SELECT a FROM Accommodation a " + + "WHERE (:cursorId IS NULL OR a.id < :cursorId) " + + "ORDER BY a.id DESC") + Slice findAllByCursor( + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} + From ca83b2496515776f3420c16a2e5726a72c140275 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 11 Dec 2025 10:39:08 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[#20]=20Feat:=20=EC=88=99=EC=86=8C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tripPlan/dto/AccommodationResponse.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/java/com/example/triptalk/domain/tripPlan/dto/AccommodationResponse.java diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/dto/AccommodationResponse.java b/src/main/java/com/example/triptalk/domain/tripPlan/dto/AccommodationResponse.java new file mode 100644 index 0000000..ec72f1d --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/dto/AccommodationResponse.java @@ -0,0 +1,67 @@ +package com.example.triptalk.domain.tripPlan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +public class AccommodationResponse { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "숙소 정보") + public static class AccommodationDTO { + + @Schema(description = "숙소 ID", example = "1") + private Long id; + + @Schema(description = "호텔 이름", example = "서울 롯데호텔") + private String hotelName; + + @Schema(description = "도시 한국어명", example = "서울") + private String cityName; + + @Schema(description = "1박 가격 (원화)", example = "150000") + private Integer pricePerNight; + + @Schema(description = "체크인 날짜", example = "2025-12-17") + private LocalDate checkInDate; + + @Schema(description = "체크아웃 날짜", example = "2025-12-19") + private LocalDate checkOutDate; + + @Schema(description = "도시 대표 이미지 URL", example = "https://images.unsplash.com/...") + private String imageUrl; + + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "숙소 목록 응답 (커서 기반)") + public static class AccommodationListResultDTO { + + @Schema(description = "숙소 목록") + private List accommodationList; + + @Schema(description = "현재 페이지의 숙소 개수", example = "10") + private Integer accommodationListSize; + + @Schema(description = "페이지 처음 여부", example = "true") + private Boolean isFirst; + + @Schema(description = "다음 페이지가 있는지 여부", example = "true") + private Boolean hasNext; + + @Schema(description = "다음 커서 ID (무한스크롤용)", example = "150") + private Long nextCursorId; + } +} + From e6796ebb426c6fe858cdf2299e72e91c88c63d5b Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 11 Dec 2025 10:39:27 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[#20]=20Feat:=20=EC=88=99=EC=86=8C=20DTO=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20Converter=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/AccommodationConverter.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/main/java/com/example/triptalk/domain/tripPlan/converter/AccommodationConverter.java diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/converter/AccommodationConverter.java b/src/main/java/com/example/triptalk/domain/tripPlan/converter/AccommodationConverter.java new file mode 100644 index 0000000..ff3fa70 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/converter/AccommodationConverter.java @@ -0,0 +1,48 @@ +package com.example.triptalk.domain.tripPlan.converter; + +import com.example.triptalk.domain.tripPlan.dto.AccommodationResponse; +import com.example.triptalk.domain.tripPlan.entity.Accommodation; +import org.springframework.data.domain.Slice; + +import java.util.List; + +public class AccommodationConverter { + + /** + * Accommodation 엔티티를 AccommodationDTO로 변환 + */ + public static AccommodationResponse.AccommodationDTO toAccommodationDTO(Accommodation accommodation) { + return AccommodationResponse.AccommodationDTO.builder() + .id(accommodation.getId()) + .hotelName(accommodation.getHotelName()) + .cityName(accommodation.getCityName()) + .pricePerNight(accommodation.getPricePerNight()) + .checkInDate(accommodation.getCheckInDate()) + .checkOutDate(accommodation.getCheckOutDate()) + .imageUrl(accommodation.getImageUrl()) + .build(); + } + + /** + * Slice를 AccommodationListResultDTO로 변환 + */ + public static AccommodationResponse.AccommodationListResultDTO toAccommodationListResultDTO(Slice slice) { + List accommodationList = slice.getContent().stream() + .map(AccommodationConverter::toAccommodationDTO) + .toList(); + + // 다음 커서 ID는 마지막 항목의 ID + Long nextCursorId = accommodationList.isEmpty() ? + null : + accommodationList.getLast().getId(); + + return AccommodationResponse.AccommodationListResultDTO.builder() + .accommodationList(accommodationList) + .accommodationListSize(accommodationList.size()) + .isFirst(slice.isFirst()) + .hasNext(slice.hasNext()) + .nextCursorId(nextCursorId) + .build(); + } +} + From db9e0266a95977be9b78fdae0ab10bac13311799 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 11 Dec 2025 10:39:47 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[#20]=20Feat:=20=EC=88=99=EC=86=8C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AccommodationService.java | 13 +++++++ .../service/AccommodationServiceImpl.java | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationService.java create mode 100644 src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationServiceImpl.java diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationService.java b/src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationService.java new file mode 100644 index 0000000..5fff8d0 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationService.java @@ -0,0 +1,13 @@ +package com.example.triptalk.domain.tripPlan.service; + +import com.example.triptalk.domain.tripPlan.dto.AccommodationResponse; + +public interface AccommodationService { + /** + * DB에 저장된 숙소 조회 (커서 기반 무한스크롤) + * @param cursorId 커서 ID (null이면 처음부터) + * @return 숙소 목록 응답 + */ + AccommodationResponse.AccommodationListResultDTO getAccommodations(Long cursorId); +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationServiceImpl.java b/src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationServiceImpl.java new file mode 100644 index 0000000..5b988f9 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/service/AccommodationServiceImpl.java @@ -0,0 +1,36 @@ +package com.example.triptalk.domain.tripPlan.service; + +import com.example.triptalk.domain.tripPlan.converter.AccommodationConverter; +import com.example.triptalk.domain.tripPlan.dto.AccommodationResponse; +import com.example.triptalk.domain.tripPlan.entity.Accommodation; +import com.example.triptalk.domain.tripPlan.repository.AccommodationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AccommodationServiceImpl implements AccommodationService { + + private final AccommodationRepository accommodationRepository; + + private static final int PAGE_SIZE = 10; // 페이지당 숙소 개수 + + @Override + public AccommodationResponse.AccommodationListResultDTO getAccommodations(Long cursorId) { + Pageable pageable = PageRequest.of(0, PAGE_SIZE); + + // 커서 기반 조회 + Slice slice = accommodationRepository.findAllByCursor(cursorId, pageable); + + // DTO 변환 + return AccommodationConverter.toAccommodationListResultDTO(slice); + } +} + From 080b8f37cfad62d64a480e1b299dda2a41aea982 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 11 Dec 2025 10:40:00 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[#20]=20Feat:=20=EC=88=99=EC=86=8C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AccommodationController.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/main/java/com/example/triptalk/domain/tripPlan/controller/AccommodationController.java diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/controller/AccommodationController.java b/src/main/java/com/example/triptalk/domain/tripPlan/controller/AccommodationController.java new file mode 100644 index 0000000..644744c --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/controller/AccommodationController.java @@ -0,0 +1,49 @@ +package com.example.triptalk.domain.tripPlan.controller; + +import com.example.triptalk.domain.tripPlan.dto.AccommodationResponse; +import com.example.triptalk.domain.tripPlan.service.AccommodationService; +import com.example.triptalk.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/accommodations") +@RequiredArgsConstructor +@Tag(name = "숙소 API", description = "추천 숙소 조회 (매주 업데이트)") +public class AccommodationController { + + private final AccommodationService accommodationService; + + @GetMapping + @Operation( + summary = "추천 숙소 조회", + description = """ + **추천 숙소를 조회합니다.** + + ### 📊 응답 데이터 + - `accommodationList`: 숙소 목록 (최대 10개씩 페이징) + - `hotelName`: 호텔 이름 (예: 서울 신라호텔) + - `cityName`: 도시 한국어명 (예: 서울) + - `pricePerNight`: 1박 가격 (원화, 예: 150000) + - `checkInDate`: 체크인 날짜 + - `checkOutDate`: 체크아웃 날짜 + - `imageUrl`: 호텔 이미지 URL + + ### 🔄 무한스크롤 사용법 + 1. **첫 요청**: `cursorId` 없이 호출 + 2. **다음 요청**: 응답의 `nextCursorId` 값을 `cursorId`에 전달 + 3. **마지막**: `hasNext`가 `false`일 때 종료 + """ + ) + public ApiResponse getAccommodations( + @Parameter(description = "커서 ID (다음 페이지 ID, 처음 요청 시 null)", example = "null") + @RequestParam(required = false) Long cursorId + ) { + AccommodationResponse.AccommodationListResultDTO response = accommodationService.getAccommodations(cursorId); + return ApiResponse.onSuccess(response); + } +} +