diff --git a/src/main/java/com/example/triptalk/TriptalkApplication.java b/src/main/java/com/example/triptalk/TriptalkApplication.java index a44eac9..f248545 100644 --- a/src/main/java/com/example/triptalk/TriptalkApplication.java +++ b/src/main/java/com/example/triptalk/TriptalkApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class TriptalkApplication { diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/controller/FlightController.java b/src/main/java/com/example/triptalk/domain/tripPlan/controller/FlightController.java new file mode 100644 index 0000000..3615403 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/controller/FlightController.java @@ -0,0 +1,62 @@ +package com.example.triptalk.domain.tripPlan.controller; + +import com.example.triptalk.domain.tripPlan.dto.FlightResponse; +import com.example.triptalk.domain.tripPlan.service.FlightService; +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/flights") +@RequiredArgsConstructor +@Tag(name = "항공권 API", description = "추천 항공권 조회 (매주 업데이트)") +public class FlightController { + + private final FlightService flightService; + + @GetMapping + @Operation( + summary = "추천 항공권 조회", + description = """ + **다양한 인기 노선의 항공권을 조회합니다.** + + ### 📅 데이터 업데이트 + - 매주 월요일 새벽 3시 자동 업데이트 + - 항상 7일 후 출발 항공권 제공 + - 총 60개 이상 노선 (노선당 최대 3개) + - Amadeus API 실시간 가격 정보 (매주 업데이트) + - 자동 환율 변환 (EUR, USD 등 → KRW) + + ### 🌍 제공 노선 + - **국내선**: 김포-제주, 인천-제주, 김포-부산, 인천-부산, 김포-대구 등 + - **일본**: 도쿄, 오사카, 후쿠오카, 삿포로, 오키나와 등 + - **중국**: 상하이, 베이징, 광저우, 선전, 시안 등 + - **동남아**: 방콕, 싱가포르, 다낭, 발리, 세부, 푸켓 등 + - **미주**: 뉴욕, LA, 샌프란시스코, 시애틀, 괌, 하와이 등 + - **유럽**: 런던, 파리, 로마, 바르셀로나, 이스탄불 등 + - **오세아니아**: 시드니, 멜버른, 오클랜드 등 + + ### 📊 응답 데이터 + - `flightList`: 항공권 목록 (최대 10개씩 페이징) + - `id`: 항공권 ID + - `originName`: 출발지 한국어명 (예: 김포, 인천) + - `destinationName`: 도착지 한국어명 (예: 제주, 나리타) + - `airlineName`: 항공사명 + 편명 (예: 대한항공 KE1019) + - `price`: 가격 (원화, KRW) - 모든 통화 자동 변환 + - `departureDate/arrivalDate`: 출발/도착 날짜 + - `isOutbound` : 출발편 여부 (true: 출발편, false: 귀국편) + + """ + ) + public ApiResponse getFlights( + @Parameter(description = "커서 ID (다음 페이지 ID, 처음 요청 시 null)", example = "null") + @RequestParam(required = false) Long cursorId + ) { + FlightResponse.FlightListResultDTO response = flightService.getFlights(cursorId); + return ApiResponse.onSuccess(response); + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/converter/AmadeusConverter.java b/src/main/java/com/example/triptalk/domain/tripPlan/converter/AmadeusConverter.java new file mode 100644 index 0000000..315e645 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/converter/AmadeusConverter.java @@ -0,0 +1,134 @@ +package com.example.triptalk.domain.tripPlan.converter; + +import com.example.triptalk.domain.tripPlan.dto.AmadeusResponse; +import com.example.triptalk.domain.tripPlan.entity.Flight; +import com.example.triptalk.domain.tripPlan.util.CountryImageMapper; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class AmadeusConverter { + + /** + * Amadeus FlightOffer를 Flight 엔티티로 변환 + * @param flightOffer Amadeus API 응답 + * @param isOutbound true: 출발편, false: 귀환편 + * @param tempId 임시 ID (사용되지 않음, DB 저장 시 자동 생성) + * @return Flight 엔티티 + */ + public static Flight toFlight( + AmadeusResponse.FlightOffer flightOffer, + Boolean isOutbound, + Long tempId + ) { + // 첫 번째 여정 정보 추출 (출발편 또는 귀환편) + AmadeusResponse.Itinerary itinerary = flightOffer.getItineraries()[isOutbound ? 0 : 1]; + AmadeusResponse.Segment firstSegment = itinerary.getSegments()[0]; + AmadeusResponse.Segment lastSegment = itinerary.getSegments()[itinerary.getSegments().length - 1]; + + // 출발/도착 시간에서 날짜 추출 + LocalDate departureDate = parseDateTime(firstSegment.getDeparture().getAt()).toLocalDate(); + LocalDate arrivalDate = parseDateTime(lastSegment.getArrival().getAt()).toLocalDate(); + + // 출발지/도착지 + String origin = firstSegment.getDeparture().getIataCode(); + String destination = lastSegment.getArrival().getIataCode(); + + // 항공사 정보 (carrier code + flight number) + String airlineName = String.format("%s %s", + getAirlineName(firstSegment.getCarrierCode()), + firstSegment.getNumber()); + + // 가격 변환 (통화에 따라 원화로 변환) + Integer price = convertToKRW( + flightOffer.getPrice().getCurrency(), + Double.parseDouble(flightOffer.getPrice().getTotal()) + ); + + // 이미지 URL (도착지 기준) + String imageUrl = CountryImageMapper.getImageUrl(destination); + + // DB 저장 시 ID는 자동 생성됨 + return Flight.builder() + .origin(origin) + .destination(destination) + .airlineName(airlineName) + .price(price) + .departureDate(departureDate) + .arrivalDate(arrivalDate) + .imageUrl(imageUrl) + .isOutbound(isOutbound) + .build(); + } + + /** + * 통화를 원화(KRW)로 변환 + * @param currency 원본 통화 코드 + * @param amount 금액 + * @return 원화로 변환된 금액 + */ + private static Integer convertToKRW(String currency, Double amount) { + // 환율 (2025년 12월 기준 대략적인 값) + double exchangeRate = switch (currency.toUpperCase()) { + case "KRW" -> 1.0; + case "USD" -> 1350.0; // 1 USD = 1,350 KRW + case "EUR" -> 1450.0; // 1 EUR = 1,450 KRW + case "JPY" -> 9.0; // 1 JPY = 9 KRW + case "CNY" -> 190.0; // 1 CNY = 190 KRW + case "THB" -> 40.0; // 1 THB = 40 KRW + case "SGD" -> 1000.0; // 1 SGD = 1,000 KRW + case "HKD" -> 175.0; // 1 HKD = 175 KRW + case "GBP" -> 1700.0; // 1 GBP = 1,700 KRW + case "AUD" -> 900.0; // 1 AUD = 900 KRW + default -> 1350.0; // 기본값: USD 환율 + }; + + return (int) (amount * exchangeRate); + } + + /** + * ISO 8601 형식의 날짜/시간 문자열을 LocalDateTime으로 파싱 + * @param dateTimeStr "2025-12-10T07:30:00" 형식 + * @return LocalDateTime + */ + private static LocalDateTime parseDateTime(String dateTimeStr) { + return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + /** + * IATA 항공사 코드를 항공사 이름으로 변환 + * @param carrierCode IATA 2자리 코드 (예: KE, OZ, 7C) + * @return 항공사 이름 + */ + private static String getAirlineName(String carrierCode) { + return switch (carrierCode) { + case "KE" -> "대한항공"; + case "OZ" -> "아시아나항공"; + case "7C" -> "제주항공"; + case "LJ" -> "진에어"; + case "TW" -> "티웨이항공"; + case "RS" -> "에어서울"; + case "BX" -> "에어부산"; + case "ZE" -> "이스타항공"; + case "4V" -> "플라이강원"; + case "NH" -> "전일본공수"; + case "JL" -> "일본항공"; + case "CZ" -> "중국남방항공"; + case "MU" -> "중국동방항공"; + case "CA" -> "중국국제항공"; + case "TG" -> "타이항공"; + case "SQ" -> "싱가포르항공"; + case "VN" -> "베트남항공"; + case "PR" -> "필리핀항공"; + case "AF" -> "에어프랑스"; + case "LH" -> "루프트한자"; + case "BA" -> "영국항공"; + case "UA" -> "유나이티드항공"; + case "AA" -> "아메리칸항공"; + case "DL" -> "델타항공"; + default -> carrierCode; // 매핑되지 않은 경우 코드 그대로 반환 + }; + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/converter/FlightConverter.java b/src/main/java/com/example/triptalk/domain/tripPlan/converter/FlightConverter.java new file mode 100644 index 0000000..42f61bd --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/converter/FlightConverter.java @@ -0,0 +1,51 @@ +package com.example.triptalk.domain.tripPlan.converter; + +import com.example.triptalk.domain.tripPlan.dto.FlightResponse; +import com.example.triptalk.domain.tripPlan.entity.Flight; +import com.example.triptalk.domain.tripPlan.util.AirportNameMapper; +import org.springframework.data.domain.Slice; + +import java.util.List; + +public class FlightConverter { + + /** + * Flight 엔티티를 FlightDTO로 변환 + */ + public static FlightResponse.FlightDTO toFlightDTO(Flight flight) { + return FlightResponse.FlightDTO.builder() + .id(flight.getId()) + .originName(AirportNameMapper.getAirportName(flight.getOrigin())) + .destinationName(AirportNameMapper.getAirportName(flight.getDestination())) + .airlineName(flight.getAirlineName()) + .price(flight.getPrice()) + .departureDate(flight.getDepartureDate()) + .arrivalDate(flight.getArrivalDate()) + .imageUrl(flight.getImageUrl()) + .isOutbound(flight.getIsOutbound()) + .build(); + } + + /** + * Slice을 FlightListResultDTO로 변환 + */ + public static FlightResponse.FlightListResultDTO toFlightListResultDTO(Slice slice) { + List flightList = slice.getContent().stream() + .map(FlightConverter::toFlightDTO) + .toList(); + + // 다음 커서 ID는 마지막 항목의 ID + Long nextCursorId = flightList.isEmpty() ? + null : + flightList.getLast().getId(); + + return FlightResponse.FlightListResultDTO.builder() + .flightList(flightList) + .flightListSize(flightList.size()) + .isFirst(slice.isFirst()) + .hasNext(slice.hasNext()) + .nextCursorId(nextCursorId) + .build(); + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/dto/AmadeusResponse.java b/src/main/java/com/example/triptalk/domain/tripPlan/dto/AmadeusResponse.java new file mode 100644 index 0000000..3afceff --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/dto/AmadeusResponse.java @@ -0,0 +1,114 @@ +package com.example.triptalk.domain.tripPlan.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +import java.util.List; + +public class AmadeusResponse { + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class AccessToken { + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Integer expiresIn; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FlightOffersResponse { + private List data; + private Object meta; // 메타 정보 (필요시 파싱) + private Object dictionaries; // 딕셔너리 정보 (필요시 파싱) + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FlightOffer { + private String id; + private String type; + private Itinerary[] itineraries; + private Price price; + private List validatingAirlineCodes; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Itinerary { + private Segment[] segments; + private String duration; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Segment { + private Departure departure; + private Arrival arrival; + private String carrierCode; + private String number; + private Aircraft aircraft; + private String duration; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Departure { + private String iataCode; + private String at; // 2025-12-10T07:30:00 + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Arrival { + private String iataCode; + private String at; // 2025-12-10T08:35:00 + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Aircraft { + private String code; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Price { + private String currency; + private String total; + private String base; + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/dto/FlightResponse.java b/src/main/java/com/example/triptalk/domain/tripPlan/dto/FlightResponse.java new file mode 100644 index 0000000..45a8d0e --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/dto/FlightResponse.java @@ -0,0 +1,72 @@ +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 FlightResponse { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "항공권 정보") + public static class FlightDTO { + + @Schema(description = "항공권 ID", example = "1") + private Long id; + + @Schema(description = "출발지 한국어명", example = "김포") + private String originName; + + @Schema(description = "도착지 한국어명", example = "제주") + private String destinationName; + + @Schema(description = "항공사 이름", example = "진에어 LJ313") + private String airlineName; + + @Schema(description = "가격", example = "45000") + private Integer price; + + @Schema(description = "출발 날짜", example = "2025-12-10") + private LocalDate departureDate; + + @Schema(description = "도착 날짜", example = "2025-12-10") + private LocalDate arrivalDate; + + @Schema(description = "이미지 URL", example = "https://images.unsplash.com/photo-1...") + private String imageUrl; + + @Schema(description = "출발편 여부", example = "true") + private Boolean isOutbound; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "항공권 목록 조회 응답 (커서 기반)") + public static class FlightListResultDTO { + + @Schema(description = "항공권 목록") + private List flightList; + + @Schema(description = "현재 페이지의 항공권 개수", example = "5") + private Integer flightListSize; + + @Schema(description = "페이지 처음 여부", example = "true") + private Boolean isFirst; + + @Schema(description = "다음 페이지가 있는지 여부", example = "true") + private Boolean hasNext; + + @Schema(description = "다음 커서 ID (무한스크롤용)", example = "10") + private Long nextCursorId; + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/entity/Flight.java b/src/main/java/com/example/triptalk/domain/tripPlan/entity/Flight.java new file mode 100644 index 0000000..fca19b8 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/entity/Flight.java @@ -0,0 +1,41 @@ +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 Flight extends BaseEntity { + + @Column(length = 50, nullable = false) + private String origin; // 출발 지역 + + @Column(length = 50, nullable = false) + private String destination; // 도착 지역 + + @Column(length = 50, nullable = false) + private String airlineName; // 항공사 이름 + + @Column(nullable = false) + private Integer price; // 가격 (원화) + + @Column(nullable = false) + private LocalDate departureDate; // 출발 날짜 + + @Column(nullable = false) + private LocalDate arrivalDate; // 도착 날짜 + + @Column(length = 255, nullable = false) + private String imageUrl; // 이미지 URL + + @Column(nullable = false) + private Boolean isOutbound; // true: 출발편, false: 귀환편 +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/repository/FlightRepository.java b/src/main/java/com/example/triptalk/domain/tripPlan/repository/FlightRepository.java new file mode 100644 index 0000000..40ada9a --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/repository/FlightRepository.java @@ -0,0 +1,26 @@ +package com.example.triptalk.domain.tripPlan.repository; + +import com.example.triptalk.domain.tripPlan.entity.Flight; +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 FlightRepository extends JpaRepository { + + /** + * 커서 기반 항공권 목록 조회 (ID 내림차순) + * @param cursorId 커서 ID (null이면 처음부터 조회) + * @param pageable 페이징 정보 + * @return Slice + */ + @Query("SELECT f FROM Flight f " + + "WHERE (:cursorId IS NULL OR f.id < :cursorId) " + + "ORDER BY f.id DESC") + Slice findAllByCursor( + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/scheduler/FlightScheduler.java b/src/main/java/com/example/triptalk/domain/tripPlan/scheduler/FlightScheduler.java new file mode 100644 index 0000000..3a8af01 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/scheduler/FlightScheduler.java @@ -0,0 +1,196 @@ +package com.example.triptalk.domain.tripPlan.scheduler; + +import com.example.triptalk.domain.tripPlan.converter.AmadeusConverter; +import com.example.triptalk.domain.tripPlan.dto.AmadeusResponse; +import com.example.triptalk.domain.tripPlan.entity.Flight; +import com.example.triptalk.domain.tripPlan.repository.FlightRepository; +import com.example.triptalk.domain.tripPlan.service.AmadeusService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FlightScheduler { + + private final AmadeusService amadeusService; + private final FlightRepository flightRepository; + + // 인기 노선 정의 - 다양한 국가 포함 + private static final List POPULAR_ROUTES = Arrays.asList( + // 국내선 + new RouteInfo("GMP", "CJU", "김포", "제주"), + new RouteInfo("ICN", "CJU", "인천", "제주"), + new RouteInfo("GMP", "PUS", "김포", "부산"), + new RouteInfo("ICN", "PUS", "인천", "부산"), + new RouteInfo("GMP", "TAE", "김포", "대구"), + + // 일본 + new RouteInfo("ICN", "NRT", "인천", "도쿄(나리타)"), + new RouteInfo("ICN", "HND", "인천", "도쿄(하네다)"), + new RouteInfo("ICN", "KIX", "인천", "오사카"), + new RouteInfo("ICN", "FUK", "인천", "후쿠오카"), + new RouteInfo("ICN", "CTS", "인천", "삿포로"), + new RouteInfo("ICN", "OKA", "인천", "오키나와"), + new RouteInfo("PUS", "NRT", "부산", "도쿄"), + new RouteInfo("PUS", "KIX", "부산", "오사카"), + + // 중국 + new RouteInfo("ICN", "PVG", "인천", "상하이"), + new RouteInfo("ICN", "PEK", "인천", "베이징"), + new RouteInfo("ICN", "CAN", "인천", "광저우"), + new RouteInfo("ICN", "SZX", "인천", "선전"), + new RouteInfo("ICN", "XIY", "인천", "시안"), + + // 대만/홍콩 + new RouteInfo("ICN", "TPE", "인천", "타이베이"), + new RouteInfo("ICN", "HKG", "인천", "홍콩"), + + // 동남아시아 + new RouteInfo("ICN", "BKK", "인천", "방콕"), + new RouteInfo("ICN", "SIN", "인천", "싱가포르"), + new RouteInfo("ICN", "KUL", "인천", "쿠알라룸푸르"), + new RouteInfo("ICN", "MNL", "인천", "마닐라"), + new RouteInfo("ICN", "SGN", "인천", "호찌민"), + new RouteInfo("ICN", "HAN", "인천", "하노이"), + new RouteInfo("ICN", "DAD", "인천", "다낭"), + new RouteInfo("ICN", "DPS", "인천", "발리"), + new RouteInfo("ICN", "CEB", "인천", "세부"), + new RouteInfo("ICN", "HKT", "인천", "푸켓"), + new RouteInfo("PUS", "BKK", "부산", "방콕"), + new RouteInfo("PUS", "SIN", "부산", "싱가포르"), + + // 미국 + new RouteInfo("ICN", "JFK", "인천", "뉴욕"), + new RouteInfo("ICN", "LAX", "인천", "로스앤젤레스"), + new RouteInfo("ICN", "SFO", "인천", "샌프란시스코"), + new RouteInfo("ICN", "SEA", "인천", "시애틀"), + new RouteInfo("ICN", "HNL", "인천", "호놀룰루"), + new RouteInfo("ICN", "GUM", "인천", "괌"), + + // 캐나다 + new RouteInfo("ICN", "YVR", "인천", "밴쿠버"), + new RouteInfo("ICN", "YYZ", "인천", "토론토"), + + // 유럽 + new RouteInfo("ICN", "LHR", "인천", "런던"), + new RouteInfo("ICN", "CDG", "인천", "파리"), + new RouteInfo("ICN", "FRA", "인천", "프랑크푸르트"), + new RouteInfo("ICN", "AMS", "인천", "암스테르담"), + new RouteInfo("ICN", "FCO", "인천", "로마"), + new RouteInfo("ICN", "MAD", "인천", "마드리드"), + new RouteInfo("ICN", "BCN", "인천", "바르셀로나"), + new RouteInfo("ICN", "ZRH", "인천", "취리히"), + new RouteInfo("ICN", "IST", "인천", "이스탄불"), + + // 중동 + new RouteInfo("ICN", "DXB", "인천", "두바이"), + new RouteInfo("ICN", "DOH", "인천", "도하"), + + // 오세아니아 + new RouteInfo("ICN", "SYD", "인천", "시드니"), + new RouteInfo("ICN", "MEL", "인천", "멜버른"), + new RouteInfo("ICN", "AKL", "인천", "오클랜드"), + new RouteInfo("ICN", "CNS", "인천", "케언스") + ); + + /** + * 매주 월요일 새벽 3시에 항공권 데이터 업데이트 + * Cron: 초 분 시 일 월 요일 + * 0 0 3 * * MON = 매주 월요일 3시 + */ + @Scheduled(cron = "0 0 3 * * MON") + @Transactional + public void updateFlights() { + log.info("=== 항공권 데이터 업데이트 시작 ==="); + + try { + // 기존 데이터 모두 삭제 + flightRepository.deleteAll(); + log.info("기존 항공권 데이터 삭제 완료"); + + // 7일 후 출발 날짜 + LocalDate departureDate = LocalDate.now().plusDays(7); + + List allFlights = new ArrayList<>(); + + // 각 인기 노선별로 항공권 조회 및 저장 + for (RouteInfo route : POPULAR_ROUTES) { + try { + log.info("노선 조회 중: {} → {}", route.originName, route.destinationName); + + AmadeusResponse.FlightOffersResponse response = amadeusService.searchFlights( + route.originCode, + route.destinationCode, + departureDate, + 1, // 성인 1명 + 2 // 노선당 최대 2개 + ); + + if (response.getData() != null && !response.getData().isEmpty()) { + for (AmadeusResponse.FlightOffer offer : response.getData()) { + Flight flight = AmadeusConverter.toFlight(offer, true, 0L); + allFlights.add(flight); + } + log.info("노선 {}건 조회 완료", response.getData().size()); + } + + // API Rate Limit 방지를 위해 대기 + Thread.sleep(1000); + + } catch (Exception e) { + log.error("노선 조회 실패: {} → {}, 에러: {}", + route.originName, route.destinationName, e.getMessage()); + } + } + + // 일괄 저장 + flightRepository.saveAll(allFlights); + log.info("=== 항공권 데이터 업데이트 완료: 총 {}건 ===", allFlights.size()); + + } catch (Exception e) { + log.error("항공권 데이터 업데이트 실패: {}", e.getMessage(), e); + } + } + + /** + * 애플리케이션 시작 시 초기 데이터 로드 + * (최초 실행 또는 DB가 비어있을 때) + */ + @Scheduled(initialDelay = 10000, fixedDelay = Long.MAX_VALUE) // 시작 10초 후 1회만 실행 + @Transactional + public void initialLoadFlights() { + long count = flightRepository.count(); + + if (count == 0) { + log.info("=== 초기 항공권 데이터 로드 시작 ==="); + updateFlights(); + } else { + log.info("기존 항공권 데이터 존재: {}건", count); + } + } + + // 노선 정보 클래스 + private static class RouteInfo { + String originCode; + String destinationCode; + String originName; + String destinationName; + + RouteInfo(String originCode, String destinationCode, String originName, String destinationName) { + this.originCode = originCode; + this.destinationCode = destinationCode; + this.originName = originName; + this.destinationName = destinationName; + } + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/service/AmadeusService.java b/src/main/java/com/example/triptalk/domain/tripPlan/service/AmadeusService.java new file mode 100644 index 0000000..e5bcf5a --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/service/AmadeusService.java @@ -0,0 +1,131 @@ +package com.example.triptalk.domain.tripPlan.service; + +import com.example.triptalk.domain.tripPlan.dto.AmadeusResponse; +import com.example.triptalk.global.config.AmadeusProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AmadeusService { + + private final AmadeusProperties amadeusProperties; + private final RestTemplate restTemplate = new RestTemplate(); + + private String accessToken; + private long tokenExpiryTime; + + /** + * Amadeus API Access Token 발급 + */ + private String getAccessToken() { + // 토큰이 유효하면 재사용 + if (accessToken != null && System.currentTimeMillis() < tokenExpiryTime) { + return accessToken; + } + + try { + String url = amadeusProperties.getBaseUrl() + "/v1/security/oauth2/token"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "client_credentials"); + body.add("client_id", amadeusProperties.getApiKey()); + body.add("client_secret", amadeusProperties.getApiSecret()); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity( + url, + request, + AmadeusResponse.AccessToken.class + ); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + AmadeusResponse.AccessToken tokenResponse = response.getBody(); + this.accessToken = tokenResponse.getAccessToken(); + // 만료 시간 설정 (현재 시간 + expires_in - 60초 여유) + this.tokenExpiryTime = System.currentTimeMillis() + (tokenResponse.getExpiresIn() - 60) * 1000L; + + log.info("Amadeus Access Token 발급 성공"); + return this.accessToken; + } + + throw new RuntimeException("Amadeus Access Token 발급 실패"); + + } catch (Exception e) { + log.error("Amadeus Access Token 발급 중 오류 발생: {}", e.getMessage()); + throw new RuntimeException("Amadeus API 인증 실패", e); + } + } + + /** + * 항공편 검색 + * @param originLocationCode 출발지 공항 코드 (IATA, 예: ICN, GMP) + * @param destinationLocationCode 도착지 공항 코드 (IATA, 예: CJU) + * @param departureDate 출발 날짜 (YYYY-MM-DD) + * @param adults 성인 승객 수 + * @param max 최대 결과 수 + * @return 항공편 목록 + */ + public AmadeusResponse.FlightOffersResponse searchFlights( + String originLocationCode, + String destinationLocationCode, + LocalDate departureDate, + Integer adults, + Integer max + ) { + try { + String token = getAccessToken(); + String url = amadeusProperties.getBaseUrl() + "/v2/shopping/flight-offers"; + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + headers.setContentType(MediaType.APPLICATION_JSON); + + // 쿼리 파라미터 구성 + String dateStr = departureDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + String fullUrl = String.format( + "%s?originLocationCode=%s&destinationLocationCode=%s&departureDate=%s&adults=%d&max=%d", + url, originLocationCode, destinationLocationCode, dateStr, adults, max + ); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.GET, + request, + AmadeusResponse.FlightOffersResponse.class + ); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + AmadeusResponse.FlightOffersResponse flightOffersResponse = response.getBody(); + + log.info("항공편 검색 성공: {} → {}, 결과 {}건", + originLocationCode, destinationLocationCode, + flightOffersResponse.getData() != null ? flightOffersResponse.getData().size() : 0); + + return flightOffersResponse; + } + + throw new RuntimeException("항공편 검색 실패"); + + } catch (Exception e) { + log.error("항공편 검색 중 오류 발생: {}", e.getMessage()); + throw new RuntimeException("Amadeus 항공편 검색 실패", e); + } + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/service/FlightService.java b/src/main/java/com/example/triptalk/domain/tripPlan/service/FlightService.java new file mode 100644 index 0000000..cbbb9e6 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/service/FlightService.java @@ -0,0 +1,13 @@ +package com.example.triptalk.domain.tripPlan.service; + +import com.example.triptalk.domain.tripPlan.dto.FlightResponse; + +public interface FlightService { + /** + * DB에 저장된 항공권 조회 (커서 기반 무한스크롤) + * @param cursorId 커서 ID (null이면 처음부터) + * @return 항공권 목록 응답 + */ + FlightResponse.FlightListResultDTO getFlights(Long cursorId); +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/service/FlightServiceImpl.java b/src/main/java/com/example/triptalk/domain/tripPlan/service/FlightServiceImpl.java new file mode 100644 index 0000000..c180707 --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/service/FlightServiceImpl.java @@ -0,0 +1,36 @@ +package com.example.triptalk.domain.tripPlan.service; + +import com.example.triptalk.domain.tripPlan.converter.FlightConverter; +import com.example.triptalk.domain.tripPlan.dto.FlightResponse; +import com.example.triptalk.domain.tripPlan.entity.Flight; +import com.example.triptalk.domain.tripPlan.repository.FlightRepository; +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 FlightServiceImpl implements FlightService { + + private final FlightRepository flightRepository; + + private static final int PAGE_SIZE = 10; // 페이지당 항공권 개수 + + @Override + public FlightResponse.FlightListResultDTO getFlights(Long cursorId) { + Pageable pageable = PageRequest.of(0, PAGE_SIZE); + + // 커서 기반 조회 + Slice slice = flightRepository.findAllByCursor(cursorId, pageable); + + // DTO 변환 + return FlightConverter.toFlightListResultDTO(slice); + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/util/AirportNameMapper.java b/src/main/java/com/example/triptalk/domain/tripPlan/util/AirportNameMapper.java new file mode 100644 index 0000000..2826d4f --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/util/AirportNameMapper.java @@ -0,0 +1,139 @@ +package com.example.triptalk.domain.tripPlan.util; + +import java.util.HashMap; +import java.util.Map; + +public class AirportNameMapper { + + private static final Map AIRPORT_NAMES = new HashMap<>(); + + static { + // 한국 + AIRPORT_NAMES.put("ICN", "인천"); + AIRPORT_NAMES.put("GMP", "김포"); + AIRPORT_NAMES.put("CJU", "제주"); + AIRPORT_NAMES.put("PUS", "김해"); + AIRPORT_NAMES.put("TAE", "대구"); + AIRPORT_NAMES.put("KWJ", "광주"); + AIRPORT_NAMES.put("RSU", "여수"); + AIRPORT_NAMES.put("USN", "울산"); + AIRPORT_NAMES.put("HIN", "사천"); + AIRPORT_NAMES.put("KPO", "포항"); + AIRPORT_NAMES.put("MWX", "무안"); + AIRPORT_NAMES.put("CJJ", "청주"); + AIRPORT_NAMES.put("YNY", "양양"); + + // 일본 + AIRPORT_NAMES.put("NRT", "나리타"); + AIRPORT_NAMES.put("HND", "도쿄"); + AIRPORT_NAMES.put("KIX", "오사카"); + AIRPORT_NAMES.put("ITM", "오사카"); + AIRPORT_NAMES.put("NGO", "나고야"); + AIRPORT_NAMES.put("FUK", "후쿠오카"); + AIRPORT_NAMES.put("CTS", "삿포로"); + AIRPORT_NAMES.put("OKA", "오키나와"); + AIRPORT_NAMES.put("KMJ", "구마모토"); + AIRPORT_NAMES.put("HIJ", "히로시마"); + + // 중국 + AIRPORT_NAMES.put("PVG", "상하이"); + AIRPORT_NAMES.put("SHA", "상하이"); + AIRPORT_NAMES.put("PEK", "베이징"); + AIRPORT_NAMES.put("PKX", "베이징"); + AIRPORT_NAMES.put("CAN", "광저우"); + AIRPORT_NAMES.put("SZX", "선전"); + AIRPORT_NAMES.put("XIY", "시안"); + AIRPORT_NAMES.put("CTU", "청두"); + AIRPORT_NAMES.put("WUH", "우한"); + AIRPORT_NAMES.put("HGH", "항저우"); + + // 대만 + AIRPORT_NAMES.put("TPE", "타이베이"); + AIRPORT_NAMES.put("TSA", "타이베이"); + AIRPORT_NAMES.put("KHH", "가오슝"); + + // 홍콩/마카오 + AIRPORT_NAMES.put("HKG", "홍콩"); + AIRPORT_NAMES.put("MFM", "마카오"); + + // 동남아시아 + AIRPORT_NAMES.put("BKK", "방콕"); + AIRPORT_NAMES.put("DMK", "방콕"); + AIRPORT_NAMES.put("SIN", "싱가포르"); + AIRPORT_NAMES.put("KUL", "쿠알라룸푸르"); + AIRPORT_NAMES.put("MNL", "마닐라"); + AIRPORT_NAMES.put("SGN", "호찌민"); + AIRPORT_NAMES.put("HAN", "하노이"); + AIRPORT_NAMES.put("DAD", "다낭"); + AIRPORT_NAMES.put("CXR", "나트랑"); + AIRPORT_NAMES.put("PQC", "푸꾸옥"); + AIRPORT_NAMES.put("DPS", "발리"); + AIRPORT_NAMES.put("CGK", "자카르타"); + AIRPORT_NAMES.put("CNX", "치앙마이"); + AIRPORT_NAMES.put("HKT", "푸켓"); + AIRPORT_NAMES.put("CEB", "세부"); + + // 미국 + AIRPORT_NAMES.put("JFK", "뉴욕"); + AIRPORT_NAMES.put("EWR", "뉴욕"); + AIRPORT_NAMES.put("LGA", "뉴욕"); + AIRPORT_NAMES.put("LAX", "로스앤젤레스"); + AIRPORT_NAMES.put("SFO", "샌프란시스코"); + AIRPORT_NAMES.put("ORD", "시카고"); + AIRPORT_NAMES.put("SEA", "시애틀"); + AIRPORT_NAMES.put("LAS", "라스베이거스"); + AIRPORT_NAMES.put("IAH", "휴스턴"); + AIRPORT_NAMES.put("HNL", "호놀룰루"); + AIRPORT_NAMES.put("GUM", "괌"); + + // 캐나다 + AIRPORT_NAMES.put("YVR", "밴쿠버"); + AIRPORT_NAMES.put("YYZ", "토론토 피어슨"); + + // 유럽 + AIRPORT_NAMES.put("LHR", "런던"); + AIRPORT_NAMES.put("LGW", "런던"); + AIRPORT_NAMES.put("CDG", "파리"); + AIRPORT_NAMES.put("ORY", "파리"); + AIRPORT_NAMES.put("FRA", "프랑크푸르트"); + AIRPORT_NAMES.put("MUC", "뮌헨"); + AIRPORT_NAMES.put("AMS", "암스테르담"); + AIRPORT_NAMES.put("FCO", "로마"); + AIRPORT_NAMES.put("MAD", "마드리드"); + AIRPORT_NAMES.put("BCN", "바르셀로나"); + AIRPORT_NAMES.put("ZRH", "취리히"); + AIRPORT_NAMES.put("VIE", "빈"); + AIRPORT_NAMES.put("PRG", "프라하"); + AIRPORT_NAMES.put("IST", "이스탄불"); + AIRPORT_NAMES.put("ATH", "아테네"); + + // 중동 + AIRPORT_NAMES.put("DXB", "두바이"); + AIRPORT_NAMES.put("AUH", "아부다비"); + AIRPORT_NAMES.put("DOH", "도하"); + + // 오세아니아 + AIRPORT_NAMES.put("SYD", "시드니"); + AIRPORT_NAMES.put("MEL", "멜버른"); + AIRPORT_NAMES.put("BNE", "브리즈번"); + AIRPORT_NAMES.put("AKL", "오클랜드"); + AIRPORT_NAMES.put("CNS", "케언스"); + + // 남미 + AIRPORT_NAMES.put("GRU", "상파울루"); + AIRPORT_NAMES.put("EZE", "부에노스아이레스"); + } + + /** + * IATA 코드를 한국어 명으로 변환 + * @param iataCode IATA 코드 (예: ICN, GMP) + * @return 한국어 명 (예: 인천, 김포) + */ + public static String getAirportName(String iataCode) { + if (iataCode == null || iataCode.trim().isEmpty()) { + return iataCode; + } + return AIRPORT_NAMES.getOrDefault(iataCode.toUpperCase(), iataCode); + } +} + diff --git a/src/main/java/com/example/triptalk/domain/tripPlan/util/CountryImageMapper.java b/src/main/java/com/example/triptalk/domain/tripPlan/util/CountryImageMapper.java new file mode 100644 index 0000000..310f66e --- /dev/null +++ b/src/main/java/com/example/triptalk/domain/tripPlan/util/CountryImageMapper.java @@ -0,0 +1,240 @@ +package com.example.triptalk.domain.tripPlan.util; + +import java.util.HashMap; +import java.util.Map; + +/** + * IATA 공항 코드를 기반으로 국가 대표 이미지를 매핑하는 유틸리티 클래스 + * + * Amadeus API에서 받은 IATA 코드(예: ICN, NRT, BKK)를 + * 해당 국가의 대표 이미지 URL로 변환합니다. + */ +public class CountryImageMapper { + + private static final Map AIRPORT_IMAGES = new HashMap<>(); + + static { + // 서울/수도권 공항 → 서울 이미지 (경복궁, 한강) + String seoulImage = "https://images.unsplash.com/photo-1532649097480-b67d52743b69?q=80&w=2232&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("ICN", seoulImage); // 인천국제공항 + AIRPORT_IMAGES.put("GMP", seoulImage); // 김포국제공항 + + // 제주 공항 → 제주도 이미지 (제주 바다) + String jejuImage = "https://images.unsplash.com/photo-1612977423916-8e4bb45b5233?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("CJU", jejuImage); // 제주국제공항 + + // 부산 공항 → 부산 이미지 (해운대 해변) + String busanImage = "https://plus.unsplash.com/premium_photo-1661963130289-aa70dd516940?q=80&w=2340&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("PUS", busanImage); // 김해국제공항 (부산) + + // 대구 공항 → 대구 이미지 (팔공산) + String daeguImage = "https://images.unsplash.com/photo-1541446201430-cf2532e3d424?q=80&w=2340&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("TAE", daeguImage); // 대구국제공항 + + // 광주 공항 → 광주 이미지 (무등산) + String gwangjuImage = "https://images.unsplash.com/photo-1638970145126-3383dcd43279?q=80&w=2340&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("KWJ", gwangjuImage); // 광주공항 + + // 여수 공항 → 여수 이미지 (여수 밤바다) + String yeosuImage = "https://plus.unsplash.com/premium_photo-1661962711053-f73d8cb0f76f?q=80&w=2340&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("RSU", yeosuImage); // 여수공항 + + // 울산 공항 → 울산 이미지 (대왕암공원) + String ulsanImage = "https://images.unsplash.com/photo-1716902923395-1d9539c2266f?q=80&w=1674&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("USN", ulsanImage); // 울산공항 + + // 청주 공항 → 청주 이미지 (속리산) + String cheongjuImage = "https://images.unsplash.com/photo-1716902923395-1d9539c2266f?q=80&w=1674&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + AIRPORT_IMAGES.put("CJJ", cheongjuImage); // 청주국제공항 + + // 일본 공항 → 일본 이미지 + String japanImage = "https://images.unsplash.com/photo-1542051841857-5f90071e7989"; + AIRPORT_IMAGES.put("NRT", japanImage); // 도쿄 나리타국제공항 + AIRPORT_IMAGES.put("HND", japanImage); // 도쿄 하네다공항 + AIRPORT_IMAGES.put("KIX", japanImage); // 오사카 간사이국제공항 + AIRPORT_IMAGES.put("ITM", japanImage); // 오사카 이타미공항 + AIRPORT_IMAGES.put("NGO", japanImage); // 나고야 주부센트레아공항 + AIRPORT_IMAGES.put("FUK", japanImage); // 후쿠오카공항 + AIRPORT_IMAGES.put("CTS", japanImage); // 삿포로 신치토세공항 + AIRPORT_IMAGES.put("OKA", japanImage); // 오키나와 나하공항 + AIRPORT_IMAGES.put("KMJ", japanImage); // 구마모토공항 + AIRPORT_IMAGES.put("HIJ", japanImage); // 히로시마공항 + + // 중국 공항 → 중국 이미지 + String chinaImage = "https://images.unsplash.com/photo-1508804185872-d7badad00f7d"; + AIRPORT_IMAGES.put("PVG", chinaImage); // 상하이 푸동국제공항 + AIRPORT_IMAGES.put("SHA", chinaImage); // 상하이 홍차오국제공항 + AIRPORT_IMAGES.put("PEK", chinaImage); // 베이징 서우두국제공항 + AIRPORT_IMAGES.put("PKX", chinaImage); // 베이징 다싱국제공항 + AIRPORT_IMAGES.put("CAN", chinaImage); // 광저우 바이윈국제공항 + AIRPORT_IMAGES.put("SZX", chinaImage); // 선전 바오안국제공항 + AIRPORT_IMAGES.put("XIY", chinaImage); // 시안 셴양국제공항 + AIRPORT_IMAGES.put("CTU", chinaImage); // 청두 솽류국제공항 + AIRPORT_IMAGES.put("WUH", chinaImage); // 우한 톈허국제공항 + AIRPORT_IMAGES.put("HGH", chinaImage); // 항저우 샤오산국제공항 + + // 대만 → 대만 이미지 + String taiwanImage = "https://images.unsplash.com/photo-1526481280693-3bfa7568e0f3"; + AIRPORT_IMAGES.put("TPE", taiwanImage); // 타이베이 타오위안국제공항 + AIRPORT_IMAGES.put("TSA", taiwanImage); // 타이베이 송산공항 + AIRPORT_IMAGES.put("KHH", taiwanImage); // 가오슝국제공항 + + // 홍콩/마카오 → 홍콩 이미지 + String hongkongImage = "https://images.unsplash.com/photo-1536599018102-9f803c140fc1"; + AIRPORT_IMAGES.put("HKG", hongkongImage); // 홍콩국제공항 + AIRPORT_IMAGES.put("MFM", hongkongImage); // 마카오국제공항 + + // 태국 → 태국 이미지 + String thailandImage = "https://images.unsplash.com/photo-1552465011-b4e21bf6e79a"; + AIRPORT_IMAGES.put("BKK", thailandImage); // 방콕 수완나품국제공항 + AIRPORT_IMAGES.put("DMK", thailandImage); // 방콕 돈므앙국제공항 + AIRPORT_IMAGES.put("CNX", thailandImage); // 치앙마이국제공항 + AIRPORT_IMAGES.put("HKT", thailandImage); // 푸켓국제공항 + + // 싱가포르 → 싱가포르 이미지 + String singaporeImage = "https://images.unsplash.com/photo-1525625293386-3f8f99389edd"; + AIRPORT_IMAGES.put("SIN", singaporeImage); // 싱가포르 창이국제공항 + + // 말레이시아 → 말레이시아 이미지 + String malaysiaImage = "https://images.unsplash.com/photo-1596422846543-75c6fc197f07"; + AIRPORT_IMAGES.put("KUL", malaysiaImage); // 쿠알라룸푸르국제공항 + + // 필리핀 → 필리핀 이미지 + String philippinesImage = "https://images.unsplash.com/photo-1506929562872-bb421503ef21"; + AIRPORT_IMAGES.put("MNL", philippinesImage); // 마닐라 니노이 아키노국제공항 + AIRPORT_IMAGES.put("CEB", philippinesImage); // 세부 막탄국제공항 + + // 베트남 → 베트남 이미지 + String vietnamImage = "https://images.unsplash.com/photo-1559592413-7cec4d0cae2b"; + AIRPORT_IMAGES.put("SGN", vietnamImage); // 호찌민 탄손낫국제공항 + AIRPORT_IMAGES.put("HAN", vietnamImage); // 하노이 노이바이국제공항 + AIRPORT_IMAGES.put("DAD", vietnamImage); // 다낭국제공항 + AIRPORT_IMAGES.put("CXR", vietnamImage); // 나트랑 캄라인국제공항 + AIRPORT_IMAGES.put("PQC", vietnamImage); // 푸꾸옥국제공항 + + // 인도네시아 → 인도네시아 이미지 + String indonesiaImage = "https://images.unsplash.com/photo-1537996194471-e657df975ab4"; + AIRPORT_IMAGES.put("DPS", indonesiaImage); // 발리 응우라라이국제공항 + AIRPORT_IMAGES.put("CGK", indonesiaImage); // 자카르타 수카르노하타국제공항 + + // 미국 → 미국 이미지 + String usaImage = "https://images.unsplash.com/photo-1485738422979-f5c462d49f74"; + AIRPORT_IMAGES.put("JFK", usaImage); // 뉴욕 존 F. 케네디국제공항 + AIRPORT_IMAGES.put("EWR", usaImage); // 뉴욕 뉴어크국제공항 + AIRPORT_IMAGES.put("LGA", usaImage); // 뉴욕 라과디아공항 + AIRPORT_IMAGES.put("LAX", usaImage); // 로스앤젤레스국제공항 + AIRPORT_IMAGES.put("SFO", usaImage); // 샌프란시스코국제공항 + AIRPORT_IMAGES.put("ORD", usaImage); // 시카고 오헤어국제공항 + AIRPORT_IMAGES.put("SEA", usaImage); // 시애틀 타코마국제공항 + AIRPORT_IMAGES.put("LAS", usaImage); // 라스베이거스 매캐런국제공항 + AIRPORT_IMAGES.put("IAH", usaImage); // 휴스턴 조지부시국제공항 + AIRPORT_IMAGES.put("HNL", usaImage); // 호놀룰루국제공항 + AIRPORT_IMAGES.put("GUM", usaImage); // 괌국제공항 + + // 캐나다 → 캐나다 이미지 + String canadaImage = "https://images.unsplash.com/photo-1503614472-8c93d56e92ce"; + AIRPORT_IMAGES.put("YVR", canadaImage); // 밴쿠버국제공항 + AIRPORT_IMAGES.put("YYZ", canadaImage); // 토론토 피어슨국제공항 + + // 영국 → 영국 이미지 + String ukImage = "https://images.unsplash.com/photo-1513635269975-59663e0ac1ad"; + AIRPORT_IMAGES.put("LHR", ukImage); // 런던 히드로공항 + AIRPORT_IMAGES.put("LGW", ukImage); // 런던 개트윅공항 + + // 프랑스 → 프랑스 이미지 + String franceImage = "https://images.unsplash.com/photo-1502602898657-3e91760cbb34"; + AIRPORT_IMAGES.put("CDG", franceImage); // 파리 샤를드골공항 + AIRPORT_IMAGES.put("ORY", franceImage); // 파리 오를리공항 + + // 독일 → 독일 이미지 + String germanyImage = "https://images.unsplash.com/photo-1467269204594-9661b134dd2b"; + AIRPORT_IMAGES.put("FRA", germanyImage); // 프랑크푸르트공항 + AIRPORT_IMAGES.put("MUC", germanyImage); // 뮌헨공항 + + // 네덜란드 → 네덜란드 이미지 + String netherlandsImage = "https://images.unsplash.com/photo-1512470876302-972faa2aa9a4"; + AIRPORT_IMAGES.put("AMS", netherlandsImage); // 암스테르담 스키폴공항 + + // 이탈리아 → 이탈리아 이미지 + String italyImage = "https://images.unsplash.com/photo-1515542622106-78bda8ba0e5b"; + AIRPORT_IMAGES.put("FCO", italyImage); // 로마 피우미치노공항 + + // 스페인 → 스페인 이미지 + String spainImage = "https://images.unsplash.com/photo-1543783207-ec64e4d95325"; + AIRPORT_IMAGES.put("MAD", spainImage); // 마드리드 바라하스공항 + AIRPORT_IMAGES.put("BCN", spainImage); // 바르셀로나 엘프라트공항 + + // 스위스 → 스위스 이미지 + String switzerlandImage = "https://images.unsplash.com/photo-1530122037265-a5f1f91d3b99"; + AIRPORT_IMAGES.put("ZRH", switzerlandImage); // 취리히공항 + + // 오스트리아 → 오스트리아 이미지 + String austriaImage = "https://images.unsplash.com/photo-1516550893923-42d28e5677af"; + AIRPORT_IMAGES.put("VIE", austriaImage); // 빈국제공항 + + // 체코 → 체코 이미지 + String czechImage = "https://images.unsplash.com/photo-1541849546-216549ae216d"; + AIRPORT_IMAGES.put("PRG", czechImage); // 프라하 바츨라프하벨공항 + + // 터키 → 터키 이미지 + String turkeyImage = "https://images.unsplash.com/photo-1524231757912-21f4fe3a7200"; + AIRPORT_IMAGES.put("IST", turkeyImage); // 이스탄불공항 + + // 그리스 → 그리스 이미지 + String greeceImage = "https://images.unsplash.com/photo-1503152394-c571994fd383"; + AIRPORT_IMAGES.put("ATH", greeceImage); // 아테네국제공항 + + // UAE → UAE 이미지 + String uaeImage = "https://images.unsplash.com/photo-1512453979798-5ea266f8880c"; + AIRPORT_IMAGES.put("DXB", uaeImage); // 두바이국제공항 + AIRPORT_IMAGES.put("AUH", uaeImage); // 아부다비국제공항 + + // 카타르 → 카타르 이미지 + String qatarImage = "https://images.unsplash.com/photo-1570544820879-53f4e9872689"; + AIRPORT_IMAGES.put("DOH", qatarImage); // 도하 하마드국제공항 + + // 호주 → 호주 이미지 + String australiaImage = "https://images.unsplash.com/photo-1506973035872-a4ec16b8e8d9"; + AIRPORT_IMAGES.put("SYD", australiaImage); // 시드니 킹스포드스미스공항 + AIRPORT_IMAGES.put("MEL", australiaImage); // 멜버른공항 + AIRPORT_IMAGES.put("BNE", australiaImage); // 브리즈번공항 + AIRPORT_IMAGES.put("CNS", australiaImage); // 케언스공항 + + // 뉴질랜드 → 뉴질랜드 이미지 + String nzImage = "https://images.unsplash.com/photo-1507699622108-4be3abd695ad"; + AIRPORT_IMAGES.put("AKL", nzImage); // 오클랜드공항 + + // 브라질 → 브라질 이미지 + String brazilImage = "https://images.unsplash.com/photo-1483729558449-99ef09a8c325"; + AIRPORT_IMAGES.put("GRU", brazilImage); // 상파울루 과룰류스국제공항 + + // 아르헨티나 → 아르헨티나 이미지 + String argentinaImage = "https://images.unsplash.com/photo-1589909202802-8f4aadce1849"; + AIRPORT_IMAGES.put("EZE", argentinaImage); // 부에노스아이레스 에세이사국제공항 + } + + /** + * IATA 공항 코드를 기반으로 국가 대표 이미지 URL 반환 + * @param iataCode IATA 공항 코드 (예: ICN, NRT, BKK) + * @return 국가 대표 이미지 URL + */ + public static String getImageUrl(String iataCode) { + if (iataCode == null || iataCode.trim().isEmpty()) { + return getDefaultImage(); + } + + String imageUrl = AIRPORT_IMAGES.get(iataCode.toUpperCase()); + + // 매핑되지 않은 공항은 기본 여행 이미지 반환 + return imageUrl != null ? imageUrl : getDefaultImage(); + } + + /** + * 기본 여행 이미지 URL 반환 + * @return 기본 이미지 URL + */ + private static String getDefaultImage() { + return "https://images.unsplash.com/photo-1436491865332-7a61a109cc05"; // 비행기 이미지 + } +} + diff --git a/src/main/java/com/example/triptalk/global/config/AmadeusProperties.java b/src/main/java/com/example/triptalk/global/config/AmadeusProperties.java new file mode 100644 index 0000000..54efe70 --- /dev/null +++ b/src/main/java/com/example/triptalk/global/config/AmadeusProperties.java @@ -0,0 +1,17 @@ +package com.example.triptalk.global.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "amadeus") +public class AmadeusProperties { + private String apiKey; + private String apiSecret; + private String baseUrl = "https://test.api.amadeus.com"; +} + 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 324b556..e1fef2d 100644 --- a/src/main/java/com/example/triptalk/global/config/SecurityConfig.java +++ b/src/main/java/com/example/triptalk/global/config/SecurityConfig.java @@ -12,6 +12,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; @Configuration @EnableWebSecurity @@ -28,6 +33,7 @@ public PasswordEncoder passwordEncoder() { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth @@ -35,11 +41,33 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/auth/**").permitAll() // 여행지 조회는 비회원도 가능 .requestMatchers("/api/trip-places/**").permitAll() + // 항공권 검색은 비회원도 가능 + .requestMatchers("/api/flights/**").permitAll() // Swagger UI 접근 허용 - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .requestMatchers( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-resources/**", + "/webjars/**" + ).permitAll() // 나머지는 인증 필요 .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } }