diff --git a/build.gradle b/build.gradle index 27b6502..f03a35f 100644 --- a/build.gradle +++ b/build.gradle @@ -36,8 +36,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' - compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' implementation 'io.jsonwebtoken:jjwt-api:0.12.3' @@ -45,7 +46,18 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'org.postgresql:postgresql' + + // Adds MapStruct, Lombok, and lombok-mapstruct-binding for DTO mapping integration + implementation 'org.mapstruct:mapstruct:1.6.3' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' annotationProcessor 'org.projectlombok:lombok' + + // Adds JTS Core and Hibernate Spatial for handling spatial data types and queries + implementation 'org.locationtech.jts:jts-core:1.20.0' + implementation 'org.hibernate.orm:hibernate-spatial:6.6.13.Final' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/io/github/petty/IndexController.java b/src/main/java/io/github/petty/IndexController.java index 882e83b..fde68b7 100644 --- a/src/main/java/io/github/petty/IndexController.java +++ b/src/main/java/io/github/petty/IndexController.java @@ -19,4 +19,14 @@ public String index(Model model) { public String recommendForm() { return "recommend"; } + + @GetMapping("/search") + public String searchPage() { + return "search"; + } + + @GetMapping("/sync") + public String syncPage() { + return "syncTest"; + } } diff --git a/src/main/java/io/github/petty/PettyApplication.java b/src/main/java/io/github/petty/PettyApplication.java index e049be9..fb0f0c1 100644 --- a/src/main/java/io/github/petty/PettyApplication.java +++ b/src/main/java/io/github/petty/PettyApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) +@EnableScheduling public class PettyApplication { - public static void main(String[] args) { SpringApplication.run(PettyApplication.class, args); } - } diff --git a/src/main/java/io/github/petty/dbsync/client/TourApiClient.java b/src/main/java/io/github/petty/dbsync/client/TourApiClient.java new file mode 100644 index 0000000..7133f97 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/client/TourApiClient.java @@ -0,0 +1,241 @@ +package io.github.petty.dbsync.client; + +import io.github.petty.dbsync.config.TourProperties; +import io.github.petty.dbsync.dto.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriBuilder; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +@Component +@Slf4j +public class TourApiClient { + + private final WebClient webClient; + private final TourProperties tourProperties; // API 설정값 주입 + + // 생성자를 통한 WebClient 및 설정 주입 + public TourApiClient(WebClient webClient, TourProperties tourProperties) { + // WebClientConfig 등에서 생성된 WebClient Bean 주입 + this.webClient = webClient; + this.tourProperties = tourProperties; + } + + // OpenApiClient 내부에 추가될 메소드들 + + /** + * 반려동물 동반여행 동기화 목록 조회 + * @param modifiedtime YYYYMMDD 형식의 수정일자 (필수) + * @param showflag '1'이면 활성 목록, null이면 전체 변경 목록 + * @param pageNo 페이지 번호 (기본값 1) + * @param numOfRows 페이지당 결과 수 (기본값 10 or 1000 등) + * @return PetTourSyncItemDto 리스트를 포함하는 Mono + */ + public Mono> fetchPetTourSyncList(String modifiedtime, String showflag, int pageNo, int numOfRows) { + // WebClient GET 요청 시작 + return webClient.get() + .uri(uriBuilder -> { // URI 및 쿼리 파라미터 설정 + // 1. API 엔드포인트 경로 설정 (실제 API 명세에 맞게 수정 필요), 필수 파라미터 추가 + uriBuilder.path("/petTourSyncList"); + uriBuilder.queryParam("serviceKey", tourProperties.getServiceKey()); + uriBuilder.queryParam("pageNo", pageNo); + uriBuilder.queryParam("numOfRows", numOfRows); + + // 3. 선택 파라미터 추가 (showflag가 null이나 빈 문자열이 아닐 경우) + if (showflag != null && !showflag.trim().isEmpty()) { + uriBuilder.queryParam("showflag", showflag); // '1'이면 활성 목록 + } + if (modifiedtime != null && !modifiedtime.trim().isEmpty()) { + uriBuilder.queryParam("modifiedtime", modifiedtime); + } + + addDefaultParams(uriBuilder); + URI uri = uriBuilder.build(); + log.debug("Request URI: {}", uri.toString()); + return uri; + }) + .retrieve() // 요청 실행 및 응답 수신 준비 + // API 응답 전체를 Wrapper DTO로 변환 (Item이 List 형태임에 유의) + .bodyToMono(new ParameterizedTypeReference>>>() {}) + .flatMap(this::handleListResponse); + + } + + /** + * 공통 정보 조회 + * @param contentId 콘텐츠 ID (필수) + * @return DetailCommonDto를 포함하는 Mono + */ + public Mono fetchDetailCommon(long contentId) { + return webClient.get() + .uri(uriBuilder -> { + uriBuilder.path("/detailCommon"); // Endpoint + uriBuilder.queryParam("serviceKey", tourProperties.getServiceKey()); + uriBuilder.queryParam("contentId", contentId); + // YN Flags as per JS reference + uriBuilder.queryParam("defaultYN", "Y"); + uriBuilder.queryParam("firstImageYN", "Y"); + uriBuilder.queryParam("areacodeYN", "Y"); + uriBuilder.queryParam("catcodeYN", "Y"); + uriBuilder.queryParam("addrinfoYN", "Y"); + uriBuilder.queryParam("mapinfoYN", "Y"); + uriBuilder.queryParam("overviewYN", "Y"); + + // Default parameters + addDefaultParams(uriBuilder); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>>() {}) + .flatMap(this::handleListResponse) + .flatMap(list -> { // 리스트에서 첫 번째 요소 추출 (이 로직은 동일) + if (list != null && !list.isEmpty()) { + return Mono.just(list.get(0)); + } else { + log.debug("fetchDetailCommon for contentId {} returned success but no items found.", contentId); + return Mono.empty(); + } + }); + + } + + /** + * 소개 정보 조회 (detailIntro) + * @param contentId 콘텐츠 ID (필수) + * @param contentTypeId 콘텐츠 타입 ID (필수) + * @return DetailIntroDto를 포함하는 Mono + */ + public Mono fetchDetailIntro(long contentId, int contentTypeId) { + return webClient.get() + .uri(uriBuilder -> { + uriBuilder.path("/detailIntro"); + uriBuilder.queryParam("serviceKey", tourProperties.getServiceKey()); + uriBuilder.queryParam("contentId", contentId); + uriBuilder.queryParam("contentTypeId", contentTypeId); + addDefaultParams(uriBuilder); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>>() {}) + .flatMap(this::handleListResponse) + .flatMap(list -> { // 리스트에서 첫 번째 요소 추출 (이 로직은 동일) + if (list != null && !list.isEmpty()) { + return Mono.just(list.get(0)); + } else { + log.debug("fetchDetailIntro for contentId {} returned success but no items found.", contentId); + return Mono.empty(); + } + }); + } + + /** + * 반복 정보 조회 (숙박/기타) (detailInfo) + * @param contentId 콘텐츠 ID (필수) + * @param contentTypeId 콘텐츠 타입 ID (필수) + * @return DetailInfoDto 리스트를 포함하는 Mono + */ + public Mono> fetchDetailInfo(long contentId, int contentTypeId) { + return webClient.get() + .uri(uriBuilder -> { + uriBuilder.path("/detailInfo"); + uriBuilder.queryParam("serviceKey", tourProperties.getServiceKey()); + uriBuilder.queryParam("contentId", contentId); + uriBuilder.queryParam("numOfRows", 50); + uriBuilder.queryParam("contentTypeId", contentTypeId); + addDefaultParams(uriBuilder); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>>() {}) + .flatMap(this::handleListResponse); + } + + /** + * 이미지 정보 조회 (detailImage) + * @param contentId 콘텐츠 ID (필수) + * @return DetailImageDto 리스트를 포함하는 Mono + */ + public Mono> fetchDetailImage(long contentId) { + return webClient.get() + .uri(uriBuilder -> { + uriBuilder.path( "/detailImage"); + uriBuilder.queryParam("serviceKey", tourProperties.getServiceKey()); + uriBuilder.queryParam("contentId", contentId); + uriBuilder.queryParam("numOfRows", 50); + uriBuilder.queryParam("imageYN", "Y"); + addDefaultParams(uriBuilder); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>>() {}) + .flatMap(this::handleListResponse); + } + + /** + * 반려동물 동반 정보 조회 (detailPetTour) + * @param contentId 콘텐츠 ID (필수) + * @return DetailPetDto를 포함하는 Mono + */ + public Mono fetchDetailPetTour(long contentId) { + return webClient.get() + .uri(uriBuilder -> { + uriBuilder.path("/detailPetTour"); + uriBuilder.queryParam("serviceKey", tourProperties.getServiceKey()); + uriBuilder.queryParam("contentId", contentId); + addDefaultParams(uriBuilder); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>>() {}) + .flatMap(this::handleListResponse) + .flatMap(list -> { // 리스트에서 첫 번째 요소 추출 (이 로직은 동일) + if (list != null && !list.isEmpty()) { + return Mono.just(list.get(0)); + } else { + log.debug("fetchDetailPetTour for contentId {} returned success but no items found.", contentId); + return Mono.empty(); + } + }); + } + + // --- Private Helper 메소드들 --- + private void addDefaultParams(UriBuilder uriBuilder) { + Map defaultParams = tourProperties.getDefaultParams(); + if (defaultParams != null) { + defaultParams.forEach(uriBuilder::queryParam); + } + } + + + private Mono> handleListResponse(ApiResponseDto>> apiResponse) { + if (apiResponse.getResponse() == null || apiResponse.getResponse().getHeader() == null) { + log.warn("Invalid API response structure: Response or Header is null"); + return Mono.just(List.of()); + } + HeaderDto header = apiResponse.getResponse().getHeader(); + if (!"0000".equals(header.getResultCode())) { + log.warn("API Error encountered - Code: {}, Message: {}", header.getResultCode(), header.getResultMsg()); + + return Mono.just(List.of()); // Return empty list on API error + } + + // Extract list of items (Null-safety) + List items = null; + if (apiResponse.getResponse().getBody() != null && + apiResponse.getResponse().getBody().getItems() != null) { + items = apiResponse.getResponse().getBody().getItems().getItem(); + } + + // Return items if present, otherwise an empty list + return Mono.just(Objects.requireNonNullElseGet(items, List::of)); + } + +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/dbsync/config/TourProperties.java b/src/main/java/io/github/petty/dbsync/config/TourProperties.java new file mode 100644 index 0000000..da22cb0 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/config/TourProperties.java @@ -0,0 +1,16 @@ +package io.github.petty.dbsync.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +@Setter +@Getter +@ConfigurationProperties(prefix = "tour-api") +public class TourProperties { + private Map defaultParams; // _type, MobileOS, MobileApp + private String baseUrl; + private String serviceKey; +} diff --git a/src/main/java/io/github/petty/dbsync/config/WebClientConfig.java b/src/main/java/io/github/petty/dbsync/config/WebClientConfig.java new file mode 100644 index 0000000..1c56865 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/config/WebClientConfig.java @@ -0,0 +1,27 @@ +package io.github.petty.dbsync.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.DefaultUriBuilderFactory; + + +/** + * WebClient 관련 설정을 위한 클래스 + */ +@Configuration +@EnableConfigurationProperties(TourProperties.class) +public class WebClientConfig { + + @Bean + public WebClient webClient(TourProperties properties) { + DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(properties.getBaseUrl()); + factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE); + + return WebClient.builder() + .uriBuilderFactory(factory) + .baseUrl(properties.getBaseUrl()) + .build(); + } +} diff --git a/src/main/java/io/github/petty/dbsync/controller/SyncController.java b/src/main/java/io/github/petty/dbsync/controller/SyncController.java new file mode 100644 index 0000000..7d7a6c7 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/controller/SyncController.java @@ -0,0 +1,31 @@ +package io.github.petty.dbsync.controller; + +import io.github.petty.dbsync.service.SyncService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/manual-sync") // API 경로 설정 +@RequiredArgsConstructor +public class SyncController { + + private final SyncService syncService; + + @PostMapping("/run") + public ResponseEntity runManualSync() { + log.info("===== 수동 동기화 요청 수신 ====="); + try { + syncService.synchronizePetTourData(); + log.info("===== 수동 동기화 요청 처리 완료 ====="); + return ResponseEntity.ok("수동 동기화 작업이 성공적으로 시작/완료되었습니다."); // 실제 완료는 비동기일 수 있음 + } catch (Exception e) { + log.error("!!!!! 수동 동기화 실행 중 오류 발생 !!!!!", e); + return ResponseEntity.internalServerError().body("수동 동기화 실행 중 오류 발생: " + e.getMessage()); + } + } +} diff --git a/src/main/java/io/github/petty/dbsync/dto/ApiResponseDto.java b/src/main/java/io/github/petty/dbsync/dto/ApiResponseDto.java new file mode 100644 index 0000000..00bfa90 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/ApiResponseDto.java @@ -0,0 +1,16 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// 최상위 응답 구조 +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ApiResponseDto { // T는 Body DTO 타입 + @JsonProperty("response") + private ResponseDto response; +} + diff --git a/src/main/java/io/github/petty/dbsync/dto/BodyDto.java b/src/main/java/io/github/petty/dbsync/dto/BodyDto.java new file mode 100644 index 0000000..026b7d3 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/BodyDto.java @@ -0,0 +1,24 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// body 필드 구조 (페이징 정보 포함) +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class BodyDto { // I는 Items DTO 타입 + @JsonProperty("items") + private I items; // 단일 객체 또는 리스트를 담는 ItemsDto + + @JsonProperty("numOfRows") + private Integer numOfRows; + + @JsonProperty("pageNo") + private Integer pageNo; + + @JsonProperty("totalCount") + private Integer totalCount; +} diff --git a/src/main/java/io/github/petty/dbsync/dto/DetailCommonDto.java b/src/main/java/io/github/petty/dbsync/dto/DetailCommonDto.java new file mode 100644 index 0000000..026e9de --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/DetailCommonDto.java @@ -0,0 +1,48 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// 1. detailCommon 용 Item DTO (petTourSyncList와 매우 유사) +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class DetailCommonDto { + @JsonProperty("contentid") + private Long contentId; + @JsonProperty("contenttypeid") + private Integer contentTypeId; + private String title; + private String addr1; + private String addr2; + @JsonProperty("areacode") + private String areaCode; + @JsonProperty("sigungucode") + private String sigunguCode; + private String cat1; + private String cat2; + private String cat3; + @JsonProperty("createdtime") + private String createdTime; // YYYYMMDDHHMMSS 형식 String + @JsonProperty("modifiedtime") + private String modifiedTime; // YYYYMMDDHHMMSS 형식 String + @JsonProperty("firstimage") + private String firstImage; + @JsonProperty("firstimage2") + private String firstImage2; + private String cpyrhtDivCd; + @JsonProperty("mapx") + private String mapX; // Decimal 형식 String + @JsonProperty("mapy") + private String mapY; // Decimal 형식 String + @JsonProperty("mlevel") + private String mlevel; + private String tel; + @JsonProperty("telname") + private String telName; + private String homepage; // HTML 포함 가능 + private String overview; // Text + private String zipcode; +} diff --git a/src/main/java/io/github/petty/dbsync/dto/DetailImageDto.java b/src/main/java/io/github/petty/dbsync/dto/DetailImageDto.java new file mode 100644 index 0000000..c798c41 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/DetailImageDto.java @@ -0,0 +1,30 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// 4. detailImage 용 Item DTO +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class DetailImageDto { + + @JsonProperty("contentid") + private Long contentId; + + @JsonProperty("imgname") + private String imgName; + + @JsonProperty("originimgurl") + private String originImgUrl; + + @JsonProperty("serialnum") + private String serialNum; + + @JsonProperty("smallimageurl") + private String smallImageUrl; + + private String cpyrhtDivCd; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/dbsync/dto/DetailInfoDto.java b/src/main/java/io/github/petty/dbsync/dto/DetailInfoDto.java new file mode 100644 index 0000000..f6c7e77 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/DetailInfoDto.java @@ -0,0 +1,143 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// 3. detailInfo 용 Item DTO (RoomInfo + ContentInfo 필드 통합) +// 실제로는 contentTypeId에 따라 사용하는 필드가 다름. +// 파싱 편의를 위해 우선 모든 필드를 포함하고, 서비스 로직에서 구분하여 사용. +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class DetailInfoDto { + + @JsonProperty("contentid") + private Long contentId; + + @JsonProperty("contenttypeid") + private Integer contentTypeId; + + // ContentInfo 필드 (contentTypeId != 32) + @JsonProperty("fldgubun") + private String fldGubun; + + @JsonProperty("infoname") + private String infoName; + + @JsonProperty("infotext") + private String infoText; + + @JsonProperty("serialnum") + private String serialNum; // ContentInfo의 serialnum + + // RoomInfo 필드 (contentTypeId == 32) + @JsonProperty("roominfono") + private String roomInfoNo; // RoomInfo의 serialnum 역할? 확인 필요 + + @JsonProperty("roomtitle") + private String roomTitle; + + @JsonProperty("roomsize1") + private String roomSize1; // 평 + + @JsonProperty("roomcount") + private Integer roomCount; + + @JsonProperty("roombasecount") + private Integer roomBaseCount; + + @JsonProperty("roommaxcount") + private Integer roomMaxCount; + + @JsonProperty("roomoffseasonminfee1") + private String roomOffSeasonMinFee1; // Decimal? or String? API 확인 필요 + + @JsonProperty("roomoffseasonminfee2") + private String roomOffSeasonMinFee2; + + @JsonProperty("roompeakseasonminfee1") + private String roomPeakSeasonMinFee1; + + @JsonProperty("roompeakseasonminfee2") + private String roomPeakSeasonMinFee2; + + @JsonProperty("roomintro") + private String roomIntro; // Text + + @JsonProperty("roombathfacility") + private String roomBathFacility; // tinyint(1) -> "1"/"0" or "Y"/"N"? API 확인 필요 + + @JsonProperty("roombath") + private String roomBath; + + @JsonProperty("roomhometheater") + private String roomHomeTheater; + + @JsonProperty("roomaircondition") + private String roomAirCondition; + + @JsonProperty("roomtv") + private String roomTv; + + @JsonProperty("roompc") + private String roomPc; + + @JsonProperty("roomcable") + private String roomCable; + + @JsonProperty("roominternet") + private String roomInternet; + + @JsonProperty("roomrefrigerator") + private String roomRefrigerator; + + @JsonProperty("roomtoiletries") + private String roomToiletries; + + @JsonProperty("roomsofa") + private String roomSofa; + + @JsonProperty("roomcook") + private String roomCook; + + @JsonProperty("roomtable") + private String roomTable; + + @JsonProperty("roomhairdryer") + private String roomHairdryer; + + @JsonProperty("roomsize2") + private String roomSize2; // 제곱미터 + + @JsonProperty("roomimg1") + private String roomImg1; + + @JsonProperty("roomimg1alt") + private String roomImg1Alt; + + @JsonProperty("roomimg2") + private String roomImg2; + + @JsonProperty("roomimg2alt") + private String roomImg2Alt; + + @JsonProperty("roomimg3") + private String roomImg3; + + @JsonProperty("roomimg3alt") + private String roomImg3Alt; + + @JsonProperty("roomimg4") + private String roomImg4; + + @JsonProperty("roomimg4alt") + private String roomImg4Alt; + + @JsonProperty("roomimg5") + private String roomImg5; + + @JsonProperty("roomimg5alt") + private String roomImg5Alt; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/dbsync/dto/DetailIntroDto.java b/src/main/java/io/github/petty/dbsync/dto/DetailIntroDto.java new file mode 100644 index 0000000..f51a523 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/DetailIntroDto.java @@ -0,0 +1,38 @@ +package io.github.petty.dbsync.dto; + +// --- Item DTO 정의 --- + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +// 2. detailIntro 용 Item DTO +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class DetailIntroDto { + @JsonProperty("contentid") + private Long contentId; + + @JsonProperty("contenttypeid") + private Integer contentTypeId; + + // contentid, contenttypeid 외의 모든 다른 필드를 이 Map에 저장 + private Map dynamicFields = new HashMap<>(); + + @JsonAnySetter // JSON의 알려지지 않은 필드를 이 메소드를 통해 Map에 추가 + public void addDynamicField(String fieldName, Object value) { + dynamicFields.put(fieldName, value); + } + +// 필요 시 dynamicFields에 대한 Getter (@Data에 포함되지만 명시적으로 보여줌) + // public Map getDynamicFields() { + // return dynamicFields; + // } +} + diff --git a/src/main/java/io/github/petty/dbsync/dto/DetailPetDto.java b/src/main/java/io/github/petty/dbsync/dto/DetailPetDto.java new file mode 100644 index 0000000..a4af375 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/DetailPetDto.java @@ -0,0 +1,26 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// 5. detailPetTour 용 Item DTO +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) + +public class DetailPetDto { + + @JsonProperty("contentid") + private Long contentId; + private String relaAcdntRiskMtr; // Text + private String acmpyTypeCd; + private String relaPosesFclty; // Text + private String relaFrnshPrdlst; // Text + private String etcAcmpyInfo; // Text + private String relaPurcPrdlst; // Text + private String acmpyPsblCpam; // Text + private String relaRntlPrdlst; // Text + private String acmpyNeedMtr; // Text +} diff --git a/src/main/java/io/github/petty/dbsync/dto/HeaderDto.java b/src/main/java/io/github/petty/dbsync/dto/HeaderDto.java new file mode 100644 index 0000000..4a3bbf3 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/HeaderDto.java @@ -0,0 +1,18 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// header 필드 구조 +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class HeaderDto { + @JsonProperty("resultCode") + private String resultCode; // "0000" 이면 정상 + + @JsonProperty("resultMsg") + private String resultMsg; // "OK" 이면 정상 +} diff --git a/src/main/java/io/github/petty/dbsync/dto/ItemsDto.java b/src/main/java/io/github/petty/dbsync/dto/ItemsDto.java new file mode 100644 index 0000000..c151217 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/ItemsDto.java @@ -0,0 +1,17 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +// items 필드 구조 (실제 데이터 item 포함) +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ItemsDto { // ItemType은 실제 데이터 DTO 또는 List + @JsonProperty("item") + private List item; +} diff --git a/src/main/java/io/github/petty/dbsync/dto/PetTourSyncItemDto.java b/src/main/java/io/github/petty/dbsync/dto/PetTourSyncItemDto.java new file mode 100644 index 0000000..ec18869 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/PetTourSyncItemDto.java @@ -0,0 +1,48 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// 6. petTourSyncList 용 Item DTO +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class PetTourSyncItemDto { + + @JsonProperty("contentid") + private Long contentId; + @JsonProperty("contenttypeid") + private Integer contentTypeId; + private String title; + private String addr1; + private String addr2; + @JsonProperty("areacode") + private String areaCode; + @JsonProperty("sigungucode") + private String sigunguCode; + private String cat1; + private String cat2; + private String cat3; + @JsonProperty("createdtime") + private String createdTime; // YYYYMMDDHHMMSS 형식 String + @JsonProperty("modifiedtime") + private String modifiedTime; // YYYYMMDDHHMMSS 형식 String + @JsonProperty("firstimage") + private String firstImage; + @JsonProperty("firstimage2") + private String firstImage2; + private String cpyrhtDivCd; + @JsonProperty("mapx") + private String mapX; // Decimal 형식 String + @JsonProperty("mapy") + private String mapY; // Decimal 형식 String + @JsonProperty("mlevel") + private String mLevel; + private String tel; + @JsonProperty("zipcode") + private String zipCode; + @JsonProperty("showflag") + private String showFlag; // "1" or null +} diff --git a/src/main/java/io/github/petty/dbsync/dto/ResponseDto.java b/src/main/java/io/github/petty/dbsync/dto/ResponseDto.java new file mode 100644 index 0000000..bdc26b2 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/dto/ResponseDto.java @@ -0,0 +1,18 @@ +package io.github.petty.dbsync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +// response 필드 내부 구조 +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ResponseDto { // T는 Body DTO 타입 + @JsonProperty("header") + private HeaderDto header; + + @JsonProperty("body") + private T body; // API 종류에 따라 Body 타입이 달라짐 +} diff --git a/src/main/java/io/github/petty/dbsync/mapper/DataMapper.java b/src/main/java/io/github/petty/dbsync/mapper/DataMapper.java new file mode 100644 index 0000000..f5bd3dc --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/mapper/DataMapper.java @@ -0,0 +1,186 @@ +package io.github.petty.dbsync.mapper; + +import io.github.petty.dbsync.dto.*; +import io.github.petty.tour.entity.*; +import org.mapstruct.*; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Mapper( + componentModel = "spring", + uses = {TypeConversionHelper.class}, + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_NULL +) +public interface DataMapper { + + /** + * 여러 API DTO를 취합하여 Content Entity 및 연관 Entity를 생성하고 관계를 설정합니다. + */ + @Mappings({ + // Content 기본 정보 매핑 () + @Mapping(target = "contentId", source = "commonDto.contentId"), + @Mapping(target = "contentTypeId", source = "commonDto.contentTypeId"), + @Mapping(target = "title", source = "commonDto.title"), + @Mapping(target = "addr1", source = "commonDto.addr1"), + @Mapping(target = "addr2", source = "commonDto.addr2"), + @Mapping(target = "areaCode", source = "commonDto.areaCode", qualifiedByName = "StringToInteger"), + @Mapping(target = "sigunguCode", source = "commonDto.sigunguCode", qualifiedByName = "StringToInteger"), + @Mapping(target = "cat1", source = "commonDto.cat1"), + @Mapping(target = "cat2", source = "commonDto.cat2"), + @Mapping(target = "cat3", source = "commonDto.cat3"), + @Mapping(target = "firstImage", source = "commonDto.firstImage"), + @Mapping(target = "firstImage2", source = "commonDto.firstImage2"), + @Mapping(target = "cpyrhtDivCd", source = "commonDto.cpyrhtDivCd"), + @Mapping(target = "createdTime", source = "commonDto.createdTime", qualifiedByName = "StringToInstant"), + @Mapping(target = "modifiedTime", source = "commonDto.modifiedTime", qualifiedByName = "StringToInstant"), + @Mapping(target = "mapX", source = "commonDto.mapX", qualifiedByName = "StringToBigDecimal"), + @Mapping(target = "mapY", source = "commonDto.mapY", qualifiedByName = "StringToBigDecimal"), + @Mapping(target = "mlevel", source = "commonDto.mlevel", qualifiedByName = "StringToInteger"), + @Mapping(target = "tel", source = "commonDto.tel"), + @Mapping(target = "telName", source = "commonDto.telName"), + @Mapping(target = "homepage", source = "commonDto.homepage"), + @Mapping(target = "overview", source = "commonDto.overview"), + @Mapping(target = "zipcode", source = "commonDto.zipcode"), + @Mapping(target = "location", source = "commonDto", qualifiedByName="dtoToPoint"), + @Mapping(target = "contentIntro", source = "introDto"), // Map DetailIntroDto -> ContentIntro + @Mapping(target = "petTourInfo", source = "petTourDto"), // Map DetailPetDto -> PetTourInfo + @Mapping(target = "contentImages", source = "imageDtoList"), // Map List -> Set + @Mapping(target = "contentInfos", ignore = true), + @Mapping(target = "roomInfos", ignore = true) + }) + Content mapToContentGraph(DetailCommonDto commonDto, + DetailIntroDto introDto, DetailPetDto petTourDto, + List imageDtoList); + + // === Public Method to Create Content Entity === + default Content toContentEntity(DetailCommonDto commonDto, + DetailIntroDto introDto, DetailPetDto petTourDto, + List infoDtoList, List imageDtoList) { + + if (commonDto == null) { + return null; + } + + // 1. Map the main graph using the internal method + Content content = mapToContentGraph(commonDto, introDto, petTourDto, imageDtoList); + // 2. Establish Bi-directional Relationships & Handle Conditional Lists + if (content != null) { + + // Set parent reference in children for JPA cascade/management + if (content.getContentIntro() != null) { + content.getContentIntro().setContent(content); + // ID is set by @MapsId during persistence, no need to set here + // content.getContentIntro().setContentId(content.getContentId()); // NO! + } + if (content.getPetTourInfo() != null) { + content.getPetTourInfo().setContent(content); + // ID is set by @MapsId during persistence + // content.getPetTourInfo().setContentId(content.getContentId()); // NO! + } + if (content.getContentImages() != null) { + content.getContentImages().forEach(img -> img.setContent(content)); + } + + + Integer contentTypeId = content.getContentTypeId(); + Set contentInfos = toContentInfoList(infoDtoList, contentTypeId); + Set roomInfos = toRoomInfoList(infoDtoList, contentTypeId); + + contentInfos.forEach(info -> info.setContent(content)); // Set parent + roomInfos.forEach(room -> room.setContent(content)); // Set parent + + content.setContentInfos(contentInfos); + content.setRoomInfos(roomInfos); + } + + return content; + } + + @Mappings({ + // ID handled by @MapsId/JPA + @Mapping(target = "contentId", ignore = true), + // Parent relationship handled by cascade/setter + @Mapping(target = "content", ignore = true), + @Mapping(target = "introDetails", source = "dynamicFields") + }) + ContentIntro mapIntroDtoToContentIntro(DetailIntroDto dto); + + + + @Mappings({ + // ID handled by @MapsId/JPA + @Mapping(target = "contentId", ignore = true), + // Parent relationship handled by cascade/setter + @Mapping(target = "content", ignore = true) + }) + PetTourInfo mapPetDtoToPetTourInfo(DetailPetDto dto); + + + + @Mappings({ + @Mapping(target = "id", ignore = true), // Auto Increment + // Parent relationship handled by cascade/setter + @Mapping(target = "content", ignore = true) + }) + ContentImage mapImageDtoToContentImage(DetailImageDto dto); + + + + + @Mappings({ + @Mapping(target = "id", ignore = true), + @Mapping(target = "content", ignore = true), // Ignore parent + }) + ContentInfo toContentInfo(DetailInfoDto dto); + + + @Mappings({ + @Mapping(target = "id", ignore = true), // Auto Increment + @Mapping(target = "roomOffSeasonMinFee1", source = "roomOffSeasonMinFee1"), + @Mapping(target = "roomOffSeasonMinFee2", source = "roomOffSeasonMinFee2"), + @Mapping(target = "roomPeakSeasonMinFee1", source = "roomPeakSeasonMinFee1"), + @Mapping(target = "roomPeakSeasonMinFee2", source = "roomPeakSeasonMinFee2"), + @Mapping(target = "roomBathFacility", source = "roomBathFacility", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomBath", source = "roomBath", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomHomeTheater", source = "roomHomeTheater", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomAirCondition", source = "roomAirCondition", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomTv", source = "roomTv", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomPc", source = "roomPc", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomCable", source = "roomCable", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomInternet", source = "roomInternet", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomRefrigerator", source = "roomRefrigerator", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomToiletries", source = "roomToiletries", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomSofa", source = "roomSofa", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomCook", source = "roomCook", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomTable", source = "roomTable", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "roomHairdryer", source = "roomHairdryer", qualifiedByName = "StringToBooleanY"), + @Mapping(target = "content", ignore = true) + + }) + RoomInfo toRoomInfo(DetailInfoDto dto); + + + default Set toContentInfoList(List dtoList, Integer contentTypeId) { + if (contentTypeId == null || contentTypeId == 32 || dtoList == null || dtoList.isEmpty()) { // 32(숙박) 제외 + return new LinkedHashSet<>(); // Return mutable empty set + } + return dtoList.stream() + .map(this::toContentInfo) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + default Set toRoomInfoList(List dtoList, Integer contentTypeId) { + if (contentTypeId == null || contentTypeId != 32 || dtoList == null || dtoList.isEmpty()) { // 32(숙박)만 해당 + return new LinkedHashSet<>(); // Return mutable empty set + } + return dtoList.stream() + .map(this::toRoomInfo) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + + +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/dbsync/mapper/TypeConversionHelper.java b/src/main/java/io/github/petty/dbsync/mapper/TypeConversionHelper.java new file mode 100644 index 0000000..6f01ac1 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/mapper/TypeConversionHelper.java @@ -0,0 +1,106 @@ +package io.github.petty.dbsync.mapper; + +import io.github.petty.dbsync.dto.DetailCommonDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; +import org.mapstruct.Named; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +@Slf4j +@Component +@RequiredArgsConstructor // ObjectMapper 주입 위해 생성자 필요 +public class TypeConversionHelper { + + private static final DateTimeFormatter API_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private static final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + + + + @Named("StringToInstant") + public Instant stringToInstant(String apiDateTime) { + if (apiDateTime == null || apiDateTime.isBlank()) { + return null; + } + try { + LocalDateTime localDateTime; + // 길이가 다른 경우 등 처리 (기존 로직 유지) + if (apiDateTime.length() == 14) { + localDateTime = LocalDateTime.parse(apiDateTime, API_DATE_TIME_FORMATTER); + } else { + return null; + } + + return localDateTime.toInstant(ZoneOffset.UTC); + + } catch (DateTimeParseException e) { + return null; + } + } + + @Named("StringToBigDecimal") + public BigDecimal stringToBigDecimal(String apiDecimal) { + if (apiDecimal == null || apiDecimal.isBlank()) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(apiDecimal); + } catch (NumberFormatException e) { + return BigDecimal.ZERO; // 변환 실패 시 + } + } + + @Named("StringToInteger") + public Integer stringToInteger(String apiInteger) { + if (apiInteger == null || apiInteger.isBlank()) { + return null; + } + try { + return Integer.parseInt(apiInteger); + } catch (NumberFormatException e) { + return null; // 변환 실패 시 + } + } + + @Named("dtoToPoint") + public Point dtoToPoint(DetailCommonDto dto) { + Point point; + double x = 0.0, y = 0.0; + try { + if (dto != null && dto.getMapX() != null && !dto.getMapX().isBlank() && + dto.getMapY() != null && !dto.getMapY().isBlank()) { + x = Double.parseDouble(dto.getMapX()); + y = Double.parseDouble(dto.getMapY()); + } else { + log.warn("MapX or MapY is null/blank for contentId {}. Defaulting coordinates to (0,0).", dto != null ? dto.getContentId() : "UNKNOWN"); + } + } catch (NumberFormatException e) { + log.warn("Failed to parse coordinates MapX='{}', MapY='{}' for contentId {}. Defaulting to (0,0). Error: {}", + dto.getMapX(), dto.getMapY(), dto.getContentId(), e.getMessage()); + // Keep x=0.0, y=0.0 as default + } catch (Exception e) { // Catch broader exceptions just in case + log.error("Unexpected error parsing coordinates for contentId {}. Defaulting to (0,0).", + dto != null ? dto.getContentId() : "UNKNOWN", e); + // Keep x=0.0, y=0.0 as default + } + point = geometryFactory.createPoint(new Coordinate(x, y)); + point.setSRID(4326); + return point; + } + + @Named("StringToBooleanY") + public Boolean stringYToBoolean(String apiFlag) { + return "Y".equalsIgnoreCase(apiFlag); + } + + +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/dbsync/service/DateSyncProcessor.java b/src/main/java/io/github/petty/dbsync/service/DateSyncProcessor.java new file mode 100644 index 0000000..d4a56eb --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/service/DateSyncProcessor.java @@ -0,0 +1,251 @@ +package io.github.petty.dbsync.service; + +import io.github.petty.dbsync.client.TourApiClient; +import io.github.petty.dbsync.dto.*; +import io.github.petty.tour.entity.Content; +import io.github.petty.tour.entity.SyncStatus; +import io.github.petty.dbsync.mapper.DataMapper; +import io.github.petty.tour.repository.ContentRepository; +import io.github.petty.tour.repository.SyncStatusRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.codec.DecodingException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DateSyncProcessor { + private final TourApiClient tourApiClient; + private final ContentRepository contentRepository; + private final DataMapper dataMapper; + private final SyncStatusRepository syncStatusRepository; // 상태 업데이트 위해 필요 + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + + /** + * 지정된 날짜 하루치에 대한 데이터 동기화를 수행합니다 (DELETE 후 INSERT/UPDATE). + * 성공 시 SyncStatus 테이블의 lastSyncDate를 업데이트 합니다. + * 이 메소드 전체가 하나의 트랜잭션으로 묶입니다. + * @param date 동기화할 날짜 + */ + @Transactional // 전체 메소드를 하나의 트랜잭션으로 실행 + public void syncForDate(LocalDate date) { + String dateString = date.format(DATE_FORMATTER); + log.debug("[{}] 동기화 처리 시작.", dateString); + + List deletionCandidates = Collections.emptyList(); + List insertionOrUpdateCandidates = Collections.emptyList(); + boolean apiErrorOccurred = false; + + + // --- Step 0a: 삭제 대상 후보 조회 (API 호출 및 예외 처리) --- + log.debug("[{}] 삭제 대상 목록 API 조회를 시작", dateString); + try { + + deletionCandidates = + tourApiClient.fetchPetTourSyncList(dateString, null, 1, 1000) + .blockOptional().orElse(Collections.emptyList()); + log.info("[{}] 삭제 대상 후보 {}건 조회 완료.", dateString, deletionCandidates.size()); + + + } catch (DecodingException e) { + deletionCandidates = handleApiDecodingError(e, dateString, "삭제 대상 조회", true); + if (deletionCandidates == null) { // handleApiDecodingError가 null을 반환하면 심각한 오류로 간주 (실제로는 List이므로 null 반환 안함) + apiErrorOccurred = true; + } + } catch (Exception e) { + // 네트워크 오류 등 API 호출 자체의 다른 오류 발생 시 + log.error("[{}] API 삭제 대상 조회 중 오류 발생", dateString, e); + apiErrorOccurred = true; // 오류 플래그 설정 + } + + // --- Step 0b: INSERT/UPDATE 대상 후보 조회 (showflag=1) --- + if (!apiErrorOccurred) { + log.debug("[{}] INSERT/UPDATE 대상 (showflag=1) 목록 API 조회를 시작합니다.", dateString); + try { + insertionOrUpdateCandidates = + tourApiClient.fetchPetTourSyncList(dateString, "1", 1, 1000) // showflag=1 사용 + .blockOptional().orElse(Collections.emptyList()); + log.info("[{}] INSERT/UPDATE 대상 (showflag=1) {}건 조회 완료.", dateString, insertionOrUpdateCandidates.size()); + } catch (DecodingException e) { + insertionOrUpdateCandidates = handleApiDecodingError(e, dateString, "INSERT/UPDATE 대상(1) 조회", true); + if (insertionOrUpdateCandidates == null) apiErrorOccurred = true; + } catch (Exception e) { + log.error("[{}] API INSERT/UPDATE 대상(1) 조회 중 예측 못한 오류 발생!", dateString, e); + apiErrorOccurred = true; + } + } + + // API 조회/처리 중 심각한 오류 발생 시 여기서 롤백 및 종료 + if (apiErrorOccurred) { + throw new RuntimeException("API 목록 처리 중 심각한 오류 발생: " + dateString); + } + + boolean dbOperationsSuccessful = false; + try { + // --- 1단계: 실제 삭제 대상 ID 결정 --- + List contentIdsToDelete = deletionCandidates.stream() + .map(PetTourSyncItemDto::getContentId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + log.info("[{}] 실제 삭제 대상 ID {}건.", dateString, contentIdsToDelete.size()); + + // --- 2단계: 데이터 삭제 --- + if (!contentIdsToDelete.isEmpty()) { + log.debug("[{}] {}건의 데이터 삭제를 시작합니다.", dateString, contentIdsToDelete.size()); + try { + long deletedCount = contentRepository.deleteAllByContentIdIn(contentIdsToDelete); + log.info("[{}] 데이터 {}건 삭제 요청 완료. 실제 삭제 수: {}", dateString, contentIdsToDelete.size(), deletedCount); + } catch (Exception e) { + log.error("[{}] 데이터 삭제 중 오류 발생!", dateString, e); + throw new RuntimeException("데이터 삭제 단계 실패: " + dateString, e); // 롤백 유도 + } + } else { + log.info("[{}] 삭제할 데이터가 없습니다.", dateString); + } + + // --- 3단계 & 4단계: INSERT/UPDATE 처리 (Fetch-then-Update 적용) --- + if (!insertionOrUpdateCandidates.isEmpty()) { + log.debug("[{}] {}건의 데이터 INSERT/UPDATE 처리를 시작합니다.", dateString, insertionOrUpdateCandidates.size()); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failureCount = new AtomicInteger(0); + + for (PetTourSyncItemDto itemDto : insertionOrUpdateCandidates) { + Long contentId = itemDto.getContentId(); + Integer contentTypeId = itemDto.getContentTypeId(); + if (contentId == null || contentTypeId == null) { + log.warn("[{}] contentId 또는 contentTypeId가 null인 항목 발견. 건너뜁니다. Item: {}", dateString, itemDto); + failureCount.incrementAndGet(); + continue; + } + + boolean itemProcessingError = false; // 개별 항목 처리 실패 플래그 + try { + // 4a. 상세 정보 API 호출 (DecodingException 처리 완료) + DetailCommonDto commonDto = null; + DetailIntroDto introDto = null; + DetailPetDto petTourDto = null; + List infoDtoList = Collections.emptyList(); + List imageDtoList = Collections.emptyList(); + boolean essentialApiFailed = false; + + try { commonDto = tourApiClient.fetchDetailCommon(contentId).block(); } + catch (DecodingException e) { commonDto = handleApiDecodingError(e, dateString, "DetailCommon("+contentId+")", false); if(commonDto==null) essentialApiFailed=true;} + catch (Exception e) { log.error("[{}] API DetailCommon({}) 조회 실패", dateString, contentId, e); essentialApiFailed=true; } // 필수 API 실패 + + try { introDto = tourApiClient.fetchDetailIntro(contentId, contentTypeId).block(); } + catch (DecodingException e) { introDto = handleApiDecodingError(e, dateString, "DetailIntro("+contentId+")", false); /* Null 허용? */ } + catch (Exception e) { log.error("[{}] API DetailIntro({}) 조회 실패", dateString, contentId, e); /* Null 허용? */ } + + try { petTourDto = tourApiClient.fetchDetailPetTour(contentId).block(); } + catch (DecodingException e) { petTourDto = handleApiDecodingError(e, dateString, "DetailPetTour("+contentId+")", false); /* Null 허용? */ } + catch (Exception e) { log.error("[{}] API DetailPetTour({}) 조회 실패", dateString, contentId, e); /* Null 허용? */ } + + try { infoDtoList = tourApiClient.fetchDetailInfo(contentId, contentTypeId).blockOptional().orElse(Collections.emptyList()); } + catch (DecodingException e) { infoDtoList = handleApiDecodingError(e, dateString, "DetailInfo("+contentId+")", true); } + catch (Exception e) { log.error("[{}] API DetailInfo({}) 조회 실패", dateString, contentId, e); /* 빈 리스트로 계속 진행 */ } + + try { imageDtoList = tourApiClient.fetchDetailImage(contentId).blockOptional().orElse(Collections.emptyList()); } + catch (DecodingException e) { imageDtoList = handleApiDecodingError(e, dateString, "DetailImage("+contentId+")", true); } + catch (Exception e) { log.error("[{}] API DetailImage({}) 조회 실패", dateString, contentId, e); /* 빈 리스트로 계속 진행 */ } + + + // 4a-extra. 필수 API 호출 실패 시 건너뛰기 + if (essentialApiFailed || commonDto == null) { + log.warn("[{}] Skipping contentId {} due to failure in fetching essential detail data from API.", dateString, contentId); + itemProcessingError = true; // 실패로 기록 + } + + if (!itemProcessingError) { + log.debug("[{}] contentId {} 신규 삽입 시작.", dateString, contentId); + Content newContent = dataMapper.toContentEntity(commonDto, introDto, petTourDto, infoDtoList, imageDtoList); + + if (newContent != null) { + // save() will persist Content and cascade to all associated children + contentRepository.save(newContent); + log.trace("[{}] contentId {} 신규 삽입 성공.", dateString, contentId); + } else { + // Handle case where mapping resulted in null (e.g., commonDto was null) + log.warn("[{}] contentId {} 매핑 결과가 null이므로 건너뜁니다.", dateString, contentId); + itemProcessingError = true; // Mark as failure if mapping fails + } + } + // --- Fetch-then-Update 로직 끝 --- + + } catch (Exception e) { // API 조회 실패 또는 DB 저장 실패 등 + itemProcessingError = true; // 실패로 기록 + log.error("[{}] contentId {} 처리 중 오류 발생. 건너뜁니다.", dateString, contentId, e); + } finally { + if(itemProcessingError) { + failureCount.incrementAndGet(); + } else { + successCount.incrementAndGet(); + } + } + } // end of for loop + + log.info("[{}] INSERT/UPDATE 처리 완료. 성공: {}건, 실패: {}건", dateString, successCount.get(), failureCount.get()); + } else { + log.info("[{}] INSERT/UPDATE 대상 데이터가 없습니다.", dateString); + } + + dbOperationsSuccessful = true; // 이 지점까지 예외 없이 도달하면 성공 간주 + + } catch (Exception e) { // 삭제 단계 또는 루프 외부의 심각한 오류 + log.error("[{}] DB 처리 중 오류 발생!", dateString, e); + dbOperationsSuccessful = false; + // @Transactional 에 의해 롤백됨, 상위 호출자에게 알리기 위해 예외 던짐 + throw e; + } finally { + if (dbOperationsSuccessful) { + updateSyncStatus(date); // 모든 작업 성공 시 상태 업데이트 + } + log.debug("[{}] 동기화 처리 종료.", dateString); + } + } + + /** + * SyncStatus 테이블의 lastSyncDate를 주어진 날짜로 업데이트합니다. + * @param successfullySyncedDate 성공적으로 동기화된 날짜 + */ + private void updateSyncStatus(LocalDate successfullySyncedDate) { + // 이 메소드는 syncForDate의 @Transactional 범위 내에서 호출됨 + try { + SyncStatus status = syncStatusRepository.findById(SyncStatus.DEFAULT_ID) + .orElse(new SyncStatus()); + status.setLastSyncDate(successfullySyncedDate); + syncStatusRepository.save(status); + log.info("SyncStatus 업데이트 완료: lastSyncDate = {}", successfullySyncedDate.format(DATE_FORMATTER)); + } catch (Exception e) { + // 상태 업데이트 실패 시, 전체 트랜잭션이 롤백되도록 RuntimeException 발생시킴 + log.error("!!!!! SyncStatus 업데이트 중 심각한 오류 발생 (lastSyncDate={}) !!!!!", + successfullySyncedDate.format(DATE_FORMATTER), e); + throw new RuntimeException("SyncStatus 업데이트 실패", e); // syncForDate 트랜잭션 롤백 유도 + } + } + + // --- Helper Method for DecodingException --- + private T handleApiDecodingError(DecodingException e, String dateString, String apiName, boolean isList) { + if (e.getMessage() != null && e.getMessage().contains("Cannot coerce empty String") && e.getMessage().contains("ItemsDto")) { + log.warn("[{}] API {} 응답 없음 (items가 빈 문자열). {} 반환.", dateString, apiName, isList ? "빈 리스트" : "null"); + // API 응답이 리스트 형태일 것으로 예상되면 빈 리스트 반환, 아니면 null 반환 + return isList ? (T) Collections.emptyList() : null; + } else { + // 예상치 못한 다른 디코딩 오류 -> 심각한 오류로 간주하고 예외 다시 던짐 + log.error("[{}] API {} 응답 처리(JSON 디코딩) 중 예상치 못한 오류 발생!", dateString, apiName, e); + throw e; + } + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/dbsync/service/SyncService.java b/src/main/java/io/github/petty/dbsync/service/SyncService.java new file mode 100644 index 0000000..a1bf7e8 --- /dev/null +++ b/src/main/java/io/github/petty/dbsync/service/SyncService.java @@ -0,0 +1,131 @@ +package io.github.petty.dbsync.service; + + +import io.github.petty.tour.entity.SyncStatus; +import io.github.petty.tour.repository.SyncStatusRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor // final 필드에 대한 생성자 자동 생성 (Lombok) +@Slf4j +public class SyncService { + + private final SyncStatusRepository syncStatusRepository; + private final DateSyncProcessor dateSyncProcessor; + + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final ZoneId KST_ZONE_ID = ZoneId.of("Asia/Seoul"); + + + @PostConstruct + public void init() { + log.info("SyncService 초기화 완료."); + } + + /** + * 매일 새벽 00:05 KST에 반려동물 동반여행 데이터 동기화 작업을 시작합니다. + * (요구사항 FR-01) + */ + + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void synchronizePetTourData() { + log.info("========== [시작] 반려동물 동반여행 데이터 일일 동기화 작업을 시작합니다. =========="); + try { + // 1. 동기화해야 할 날짜 목록 계산 (따라잡기 로직 포함)(실패 시 예외 발생) + List datesToSync = getSyncDatesToProcess(); + + if (datesToSync.isEmpty()) { + log.info("동기화할 새로운 날짜가 없습니다. 어제 날짜까지의 동기화가 완료된 상태입니다."); + return; + } + + log.info("동기화 대상 날짜 ({}개): {}", datesToSync.size(), datesToSync); + + // 2. 각 날짜에 대해 순차적으로 동기화 수행 + for (LocalDate date : datesToSync) { + try { + log.info("--- [{}] 날짜 데이터 동기화 시작 ---", date.format(DATE_FORMATTER)); + dateSyncProcessor.syncForDate(date); // 특정 날짜 동기화 실행 + log.info("--- [{}] 날짜 데이터 동기화 성공 ---", date.format(DATE_FORMATTER)); + } catch (Exception e) { + // syncForDate 실패 시: 에러 로그 남기고 예외를 다시 던짐 + log.error("!!! [{}] 날짜 데이터 동기화 중 심각한 오류 발생. 전체 동기화 작업을 중단합니다. !!!", date.format(DATE_FORMATTER), e); + // 필요시 Metric 등 실패 알림 추가 + throw e; + } + } + log.info("요청된 모든 날짜({})의 동기화 처리가 완료되었습니다.", datesToSync); + } catch (Exception e) { + log.error("!!!!! 데이터 동기화 작업 실행 중 오류 발생하여 중단됨 !!!!!", e); + // 필요시 Metric 등 심각 오류 알림 추가 + } finally { + log.info("========== [종료] 반려동물 동반여행 데이터 일일 동기화 작업을 종료합니다. =========="); + } + } + + /** + * 동기화해야 할 날짜 목록을 계산합니다. + * SyncStatus 테이블에서 마지막 성공 날짜를 조회하고, 그 다음날부터 어제 날짜까지의 목록을 반환합니다. + * (요구사항 FR-03, FR-04, FR-05) + * @return 동기화 대상 날짜(LocalDate) 리스트 + */ + private List getSyncDatesToProcess() { + log.debug("동기화 대상 날짜 범위 계산 시작..."); + + // 1. SyncStatus 테이블에서 마지막 성공 날짜 조회 + SyncStatus status = syncStatusRepository.findById(SyncStatus.DEFAULT_ID) + .orElseThrow(() -> { + // 에러 로그 남기고 예외 던지기 + log.error("!!!!!!!! 필수 SyncStatus 레코드(ID: {})를 DB에서 찾을 수 없습니다. 동기화 시작 날짜를 결정할 수 없어 동기화를 중단합니다. !!!!!!!!", SyncStatus.DEFAULT_ID); + return new IllegalStateException("필수 SyncStatus 레코드(ID: " + SyncStatus.DEFAULT_ID + ")가 DB에 존재하지 않습니다."); + }); + + // 2. 마지막 성공 날짜 가져오기 (status는 null 아님 보장) + LocalDate lastSyncedDate = status.getLastSyncDate(); + + // 3. 마지막 성공 날짜가 null인 경우 처리 (초기 상태) - 예외 발생 + if (lastSyncedDate == null) { + log.error("!!!!!!!! SyncStatus 레코드(ID: {})는 찾았으나 lastSyncDate 필드가 null입니다. 초기 날짜 설정이 필요합니다. 동기화를 중단합니다. !!!!!!!!", SyncStatus.DEFAULT_ID); + throw new IllegalStateException("SyncStatus 레코드(ID: " + SyncStatus.DEFAULT_ID + ")의 lastSyncDate가 설정되지 않았습니다. 초기 설정이 필요합니다."); + } + + log.debug("마지막 동기화 성공 날짜: {}", lastSyncedDate.format(DATE_FORMATTER)); + + // 4. 동기화 목표 마지막 날짜 결정 (어제) + LocalDate targetEndDate = LocalDate.now(KST_ZONE_ID).minusDays(1); + log.debug("동기화 목표 마지막 날짜: {}", targetEndDate.format(DATE_FORMATTER)); + + + // 5. 비교 및 날짜 목록 생성 + if (!lastSyncedDate.isBefore(targetEndDate)) { + log.info("마지막 동기화 날짜({})가 목표 종료 날짜({})보다 같거나 이후입니다. 추가 동기화 대상이 없습니다.", + lastSyncedDate.format(DATE_FORMATTER), targetEndDate.format(DATE_FORMATTER)); + return Collections.emptyList(); // 동기화할 필요 없음 (정상 케이스) + } + + // lastSyncDate 다음 날부터 targetEndDate (어제) 까지의 날짜 리스트 생성 + LocalDate nextSyncStartDate = lastSyncedDate.plusDays(1); + List datesToSync = nextSyncStartDate + .datesUntil(targetEndDate.plusDays(1)) // datesUntil은 endExclusive 이므로 +1일 + .collect(Collectors.toList()); // Stream -> List + + log.info("계산된 동기화 대상 날짜 범위: {} ~ {}", + nextSyncStartDate.format(DATE_FORMATTER), targetEndDate.format(DATE_FORMATTER)); + + return datesToSync; + } + + +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/controller/TourController.java b/src/main/java/io/github/petty/tour/controller/TourController.java new file mode 100644 index 0000000..66dbcb5 --- /dev/null +++ b/src/main/java/io/github/petty/tour/controller/TourController.java @@ -0,0 +1,108 @@ +package io.github.petty.tour.controller; + + +import io.github.petty.tour.dto.CodeNameDto; +import io.github.petty.tour.dto.DetailCommonDto; +import io.github.petty.tour.dto.TourSummaryDto; +import io.github.petty.tour.service.TourService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api/v1/contents") +@RequiredArgsConstructor +public class TourController { + + private final TourService tourService; + + /** + * Endpoint to get all Area codes, returned as CodeNameDto list. + * GET /api/v1/codes/areas + * @return ResponseEntity containing a list of CodeNameDto (Areas). + */ + @GetMapping("/codes") + public ResponseEntity> getAreaCodes(@RequestParam(name = "areaCode", required = false) Integer areaCode) { + if (areaCode == null) { + log.info("Request received for all area codes"); + }else { + log.info("Request received for sigungu codes with areaCode: {}", areaCode); + } + List areas = tourService.getAreas(areaCode); + + return ResponseEntity.ok(areas); + } + + + + /** + * 특정 콘텐츠의 상세 정보를 조회합니다. + * GET /api/v1/contents/{contentId} + * @param contentId 조회할 콘텐츠 ID + * @return ResponseEntity + */ + @GetMapping("/{contentId}") + public ResponseEntity getContentById(@PathVariable Long contentId) { + log.info("Request received for content detail: contentId={}", contentId); + DetailCommonDto contentDetail = tourService.getContentDetailById(contentId); + return ResponseEntity.ok(contentDetail); + } + + /** + * 지역 기반으로 콘텐츠 목록을 검색합니다 (페이징 포함). + * GET /api/v1/contents/search/area?areaCode=...&sigunguCode=...&contentTypeId=...&page=...&size=...&sort=... + * *@param areaCode 지역 코드 (필수) + * @param sigunguCode 시군구 코드 (선택) + * @param contentTypeId 콘텐츠 타입 ID (선택) + * @param pageable 페이징 정보 (기본값: size 10, 생성일 내림차순 정렬) + * @return ResponseEntity> + */ + @GetMapping("/search/area") + public ResponseEntity> searchByArea( + @RequestParam Integer areaCode, + @RequestParam(name = "sigunguCode", required = false) Integer sigunguCode, + @RequestParam(required = false) Integer contentTypeId, + @PageableDefault(size = 10, sort = "modifiedTime", direction = Sort.Direction.DESC) Pageable pageable) { + + log.info("Request received for area search: areaCode={}, sigunguCode={}, contentTypeId={}, pageable={}", + areaCode, sigunguCode, contentTypeId, pageable); + Page results = tourService.searchByArea(areaCode, sigunguCode, contentTypeId, pageable); + return ResponseEntity.ok(results); + } + + + /** + * 위치 기반으로 주변 콘텐츠 목록을 검색합니다 (페이징, 거리순 정렬 기본). + * GET /api/v1/contents/search/location?mapX=...&mapY=...&radius=...&contentTypeId=...&page=...&size=... + * @param mapX 중심점 경도 (longitude) (필수) + * @param mapY 중심점 위도 (latitude) (필수) + * @param radius 검색 반경 (미터 단위) (필수, e.g., 5000 for 5km) + * @param contentTypeId 콘텐츠 타입 ID (선택) + * @param pageable 페이징 정보 (page, size - sort는 거리순으로 고정될 수 있음) + * @return ContentSummaryDto 페이지 (거리 정보 포함 가능) 포함 ResponseEntity + */ + @GetMapping("/search/location") + public ResponseEntity> searchByLocation( + @RequestParam BigDecimal mapX, + @RequestParam BigDecimal mapY, + @RequestParam Integer radius, + @RequestParam(required = false) Integer contentTypeId, + @PageableDefault(size = 10) Pageable pageable) { + + log.info("Request received for location search: mapX={}, mapY={}, radius={}, contentTypeId={}, pageable={}", + mapX, mapY, radius, contentTypeId, pageable); + + Page results = tourService.searchByLocation(mapX, mapY, radius, contentTypeId, pageable); + return ResponseEntity.ok(results); + } + +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/dto/CodeNameDto.java b/src/main/java/io/github/petty/tour/dto/CodeNameDto.java new file mode 100644 index 0000000..e71841a --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/CodeNameDto.java @@ -0,0 +1,16 @@ +package io.github.petty.tour.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 지역(Area) 또는 시군구(Sigungu)의 코드와 이름을 담는 공통 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CodeNameDto { + private Integer code; + private String name; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/dto/DetailCommonDto.java b/src/main/java/io/github/petty/tour/dto/DetailCommonDto.java new file mode 100644 index 0000000..e41e710 --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/DetailCommonDto.java @@ -0,0 +1,45 @@ +package io.github.petty.tour.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class DetailCommonDto { + private Long contentId; + private Integer contentTypeId; + private String title; + private String addr1; + private String addr2; + private Integer areaCode; + private Integer sigunguCode; + private String cat1; + private String cat2; + private String cat3; + private Instant createdTime; + private Instant modifiedTime; + private String firstImage; + private String firstImage2; + private String cpyrhtDivCd; + private BigDecimal mapX; + private BigDecimal mapY; + private Integer mlevel; + private String tel; + private String telName; + private String homepage; + private String overview; + private String zipcode; + + // Related data (using nested DTOs is recommended) + private List images; + private List infos; + private List rooms; + private DetailIntroDto introDetails; + private DetailPetDto petTourInfo; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/dto/DetailImageDto.java b/src/main/java/io/github/petty/tour/dto/DetailImageDto.java new file mode 100644 index 0000000..3d38609 --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/DetailImageDto.java @@ -0,0 +1,18 @@ +package io.github.petty.tour.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class DetailImageDto { + + private Long contentId; + private String imgName; + private String originImgUrl; + private String serialNum; + private String smallImageUrl; + private String cpyrhtDivCd; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/dto/DetailInfoDto.java b/src/main/java/io/github/petty/tour/dto/DetailInfoDto.java new file mode 100644 index 0000000..6fff795 --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/DetailInfoDto.java @@ -0,0 +1,22 @@ +package io.github.petty.tour.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@NoArgsConstructor +public class DetailInfoDto { + + private Long contentId; + private Integer contentTypeId; + + // ContentInfo 필드 (contentTypeId != 32) + private String fldGubun; + private String infoName; + private String infoText; + private String serialNum; + +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/dto/DetailIntroDto.java b/src/main/java/io/github/petty/tour/dto/DetailIntroDto.java new file mode 100644 index 0000000..f295fae --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/DetailIntroDto.java @@ -0,0 +1,19 @@ +package io.github.petty.tour.dto; + +// --- Item DTO 정의 --- + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.HashMap; +import java.util.Map; + +// 2. detailIntro 용 Item DTO +@Getter +@Setter +@NoArgsConstructor +public class DetailIntroDto { + private Map introDetails = new HashMap<>(); +} + diff --git a/src/main/java/io/github/petty/tour/dto/DetailPetDto.java b/src/main/java/io/github/petty/tour/dto/DetailPetDto.java new file mode 100644 index 0000000..91ad04e --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/DetailPetDto.java @@ -0,0 +1,22 @@ +package io.github.petty.tour.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class DetailPetDto { + + private Long contentId; + private String relaAcdntRiskMtr; + private String acmpyTypeCd; + private String relaPosesFclty; + private String relaFrnshPrdlst; + private String etcAcmpyInfo; + private String relaPurcPrdlst; + private String acmpyPsblCpam; + private String relaRntlPrdlst; + private String acmpyNeedMtr; +} diff --git a/src/main/java/io/github/petty/tour/dto/RoomInfoDto.java b/src/main/java/io/github/petty/tour/dto/RoomInfoDto.java new file mode 100644 index 0000000..ab884b4 --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/RoomInfoDto.java @@ -0,0 +1,53 @@ +package io.github.petty.tour.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@NoArgsConstructor +public class RoomInfoDto { + + private Long contentId; + private Integer contentTypeId; + + // RoomInfo 필드 (contentTypeId == 32) + private String roomInfoNo; + private String roomTitle; + private String roomSize1; + private Integer roomCount; + private Integer roomBaseCount; + private Integer roomMaxCount; + private String roomOffSeasonMinFee1; + private String roomOffSeasonMinFee2; + private String roomPeakSeasonMinFee1; + private String roomPeakSeasonMinFee2; + private String roomIntro; + private String roomBathFacility; + private String roomBath; + private String roomHomeTheater; + private String roomAirCondition; + private String roomTv; + private String roomPc; + private String roomCable; + private String roomInternet; + private String roomRefrigerator; + private String roomToiletries; + private String roomSofa; + private String roomCook; + private String roomTable; + private String roomHairdryer; + private String roomSize2; // 제곱미터 + private String roomImg1; + private String roomImg1Alt; + private String roomImg2; + private String roomImg2Alt; + private String roomImg3; + private String roomImg3Alt; + private String roomImg4; + private String roomImg4Alt; + private String roomImg5; + private String roomImg5Alt; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/dto/TourSummaryDto.java b/src/main/java/io/github/petty/tour/dto/TourSummaryDto.java new file mode 100644 index 0000000..881dc60 --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/TourSummaryDto.java @@ -0,0 +1,21 @@ +package io.github.petty.tour.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@NoArgsConstructor +public class TourSummaryDto { + private Long contentId; + private String title; + private String addr1; + private Integer contentTypeId; + private String firstImage; // Thumbnail + private String cat1; + + // Optional: Add distance for location-based search results + private Double distanceMeters; +} diff --git a/src/main/java/io/github/petty/tour/dto/TourSummaryProjection.java b/src/main/java/io/github/petty/tour/dto/TourSummaryProjection.java new file mode 100644 index 0000000..3e971cb --- /dev/null +++ b/src/main/java/io/github/petty/tour/dto/TourSummaryProjection.java @@ -0,0 +1,16 @@ +package io.github.petty.tour.dto; + + +/** + * findByLocationNative 네이티브 쿼리의 결과를 담기 위한 프로젝션 인터페이스. + * 각 getter 메소드는 SELECT 절의 컬럼 또는 별칭과 매칭됩니다. + */ +public interface TourSummaryProjection { + Long getContentId(); + String getTitle(); + String getAddr1(); + Integer getContentTypeId(); + String getFirstImage(); // DB의 firstimage 컬럼 값이지만, 쿼리에서 'firstImage' 별칭 사용 + String getCat1(); + Double getDistance(); // 계산된 거리 값 (미터 단위) +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/entity/Area.java b/src/main/java/io/github/petty/tour/entity/Area.java new file mode 100644 index 0000000..3d7c148 --- /dev/null +++ b/src/main/java/io/github/petty/tour/entity/Area.java @@ -0,0 +1,26 @@ +package io.github.petty.tour.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "area") +@Getter +@Setter // Or make fields final and use constructor if immutable +public class Area { + + @Id + @Column(name = "areacode") + private Integer areaCode; + + @Column(name = "areaname", nullable = false, length = 20) + private String areaName; + + // Default constructor, equals, hashCode, toString (Lombok can generate) + // Consider adding @OneToMany relationship to Sigungu if needed elsewhere, + // but not strictly required for this code lookup functionality. +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/entity/Content.java b/src/main/java/io/github/petty/tour/entity/Content.java index 8aeb880..785e300 100644 --- a/src/main/java/io/github/petty/tour/entity/Content.java +++ b/src/main/java/io/github/petty/tour/entity/Content.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.locationtech.jts.geom.Point; import java.math.BigDecimal; import java.time.Instant; @@ -83,6 +84,9 @@ public class Content { @Column(name = "zipcode", length = 10) private String zipcode; + @Column(columnDefinition = "POINT SRID 4326") // DB 컬럼 타입 지정 및 NULL 허용 + private Point location; + @OneToMany(mappedBy = "content", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private Set contentImages = new LinkedHashSet<>(); @@ -92,7 +96,6 @@ public class Content { @OneToMany(mappedBy = "content", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private Set roomInfos = new LinkedHashSet<>(); - // mappedBy 값은 자식 엔티티의 부모 참조 필드 이름 ('content')과 일치 @OneToOne(mappedBy = "content", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private ContentIntro contentIntro; diff --git a/src/main/java/io/github/petty/tour/entity/Sigungu.java b/src/main/java/io/github/petty/tour/entity/Sigungu.java new file mode 100644 index 0000000..6607546 --- /dev/null +++ b/src/main/java/io/github/petty/tour/entity/Sigungu.java @@ -0,0 +1,26 @@ +package io.github.petty.tour.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "sigungu") +@IdClass(SigunguId.class) // Specify the composite key class +@Getter +@Setter +public class Sigungu { + + @Id + @Column(name = "areacode") + private Integer areaCode; + + @Id + @Column(name = "sigungucode") + private Integer sigunguCode; + + @Column(name = "sigunguname", nullable = false, length = 20) + private String sigunguName; + + +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/entity/SigunguId.java b/src/main/java/io/github/petty/tour/entity/SigunguId.java new file mode 100644 index 0000000..659f05a --- /dev/null +++ b/src/main/java/io/github/petty/tour/entity/SigunguId.java @@ -0,0 +1,18 @@ +package io.github.petty.tour.entity; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode // Important for composite keys +public class SigunguId implements Serializable { + private Integer areaCode; + private Integer sigunguCode; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/exception/ResourceNotFoundException.java b/src/main/java/io/github/petty/tour/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..8b94bf1 --- /dev/null +++ b/src/main/java/io/github/petty/tour/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package io.github.petty.tour.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/io/github/petty/tour/mapper/ContentMapper.java b/src/main/java/io/github/petty/tour/mapper/ContentMapper.java new file mode 100644 index 0000000..0d42558 --- /dev/null +++ b/src/main/java/io/github/petty/tour/mapper/ContentMapper.java @@ -0,0 +1,111 @@ +package io.github.petty.tour.mapper; + + +import io.github.petty.tour.dto.*; +import io.github.petty.tour.entity.*; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +import java.math.BigDecimal; +import java.util.List; + +// MapStruct Mapper 설정: Spring 컴포넌트로 만들고, 필요한 매퍼를 주입받을 수 있도록 설정 +@Mapper(componentModel = "spring") +public interface ContentMapper { + +// --- Content -> DetailCommonDto --- + + @Mapping(target = "images", ignore = true) + @Mapping(target = "infos", ignore = true) + @Mapping(target = "rooms", ignore = true) + @Mapping(target = "introDetails", ignore = true) + DetailCommonDto contentToDetailCommonDto(Content content); + + + // --- ContentImage -> DetailImageDto --- + @Mapping(target = "contentId", ignore = true) + DetailImageDto contentImageToDetailImageDto(ContentImage image); + + // --- ContentInfo -> DetailInfoDto --- + @Mapping(target = "contentId", ignore = true) + @Mapping(target = "contentTypeId", ignore = true) + DetailInfoDto contentInfoToDetailInfoDto(ContentInfo info); + + // --- ContentIntro -> DetailIntroDto --- + DetailIntroDto contentIntroToDetailIntroDto(ContentIntro intro); + + // --- PetTourInfo -> DetailPetDto --- + DetailPetDto petTourInfoToDetailPetDto(PetTourInfo petInfo); + + // --- RoomInfo -> RoomInfoDto --- + @Mapping(target = "contentId", ignore = true) + @Mapping(target = "contentTypeId", ignore = true) + @Mapping(source = "roomOffSeasonMinFee1", target = "roomOffSeasonMinFee1", qualifiedByName = "bigDecimalToString") + @Mapping(source = "roomOffSeasonMinFee2", target = "roomOffSeasonMinFee2", qualifiedByName = "bigDecimalToString") + @Mapping(source = "roomPeakSeasonMinFee1", target = "roomPeakSeasonMinFee1", qualifiedByName = "bigDecimalToString") + @Mapping(source = "roomPeakSeasonMinFee2", target = "roomPeakSeasonMinFee2", qualifiedByName = "bigDecimalToString") + @Mapping(source = "roomBathFacility", target = "roomBathFacility", qualifiedByName = "booleanToYN") + @Mapping(source = "roomBath", target = "roomBath", qualifiedByName = "booleanToYN") + @Mapping(source = "roomHomeTheater", target = "roomHomeTheater", qualifiedByName = "booleanToYN") + @Mapping(source = "roomAirCondition", target = "roomAirCondition", qualifiedByName = "booleanToYN") + @Mapping(source = "roomTv", target = "roomTv", qualifiedByName = "booleanToYN") + @Mapping(source = "roomPc", target = "roomPc", qualifiedByName = "booleanToYN") + @Mapping(source = "roomCable", target = "roomCable", qualifiedByName = "booleanToYN") + @Mapping(source = "roomInternet", target = "roomInternet", qualifiedByName = "booleanToYN") + @Mapping(source = "roomRefrigerator", target = "roomRefrigerator", qualifiedByName = "booleanToYN") + @Mapping(source = "roomToiletries", target = "roomToiletries", qualifiedByName = "booleanToYN") + @Mapping(source = "roomSofa", target = "roomSofa", qualifiedByName = "booleanToYN") + @Mapping(source = "roomCook", target = "roomCook", qualifiedByName = "booleanToYN") + @Mapping(source = "roomTable", target = "roomTable", qualifiedByName = "booleanToYN") + @Mapping(source = "roomHairdryer", target = "roomHairdryer", qualifiedByName = "booleanToYN") + RoomInfoDto roomInfoToRoomInfoDto(RoomInfo room); + + // --- Projection -> TourSummaryDto 매핑 메소드 --- + @Mapping(source = "distance", target = "distanceMeters") + TourSummaryDto projectionToTourSummaryDto(TourSummaryProjection projection); + + + // Area -> CodeNameDto 매핑 + @Mapping(source = "areaCode", target = "code") // 필드 이름이 다를 경우 명시적 매핑 + @Mapping(source = "areaName", target = "name") + CodeNameDto areaToCodeNameDto(Area area); + List areasToCodeNameDtos(List areas); + + // Sigungu -> CodeNameDto 매핑 + @Mapping(source = "sigunguCode", target = "code") // 필드 이름이 다를 경우 명시적 매핑 + @Mapping(source = "sigunguName", target = "name") + CodeNameDto sigunguToCodeNameDto(Sigungu sigungu); + List sigungusToCodeNameDtos(List sigungus); + + // --- Custom Mapping Helper Methods --- + + @Named("bigDecimalToString") + default String bigDecimalToString(BigDecimal value) { + return (value != null) ? value.stripTrailingZeros().toPlainString() : null; + // stripTrailingZeros() 는 10.00 -> 10 으로 변환 + // toPlainString() 은 지수 표현(e.g., 1E+1) 대신 일반 숫자 문자열로 변환 + } + + @Named("booleanToYN") + default String booleanToYN(Boolean value) { + if (value == null) { + return null; // 또는 "N" 또는 빈 문자열 등 요구사항에 맞게 처리 + } + return value ? "Y" : "N"; // 또는 "1" : "0" + } + + // Set -> List 변환이 자동으로 안 될 경우 명시적으로 정의 가능 + // (하지만 보통 MapStruct가 단일 매핑 메소드를 보고 자동으로 처리해줌) + /* + default List contentImagesToDetailImageDtos(Set images) { + if (images == null) { + return null; + } + return images.stream() + .map(this::contentImageToDetailImageDto) // this:: 사용 주의 (default 메소드 내) + .collect(Collectors.toList()); + } + // 다른 Set -> List 변환도 유사하게 정의 가능 + */ +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/repository/AreaRepository.java b/src/main/java/io/github/petty/tour/repository/AreaRepository.java new file mode 100644 index 0000000..15fc483 --- /dev/null +++ b/src/main/java/io/github/petty/tour/repository/AreaRepository.java @@ -0,0 +1,12 @@ +package io.github.petty.tour.repository; + +import io.github.petty.tour.entity.Area; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AreaRepository extends JpaRepository { // Entity is Area, ID type is Integer +} + diff --git a/src/main/java/io/github/petty/tour/repository/ContentRepository.java b/src/main/java/io/github/petty/tour/repository/ContentRepository.java index 3aca51e..dadef17 100644 --- a/src/main/java/io/github/petty/tour/repository/ContentRepository.java +++ b/src/main/java/io/github/petty/tour/repository/ContentRepository.java @@ -1,21 +1,23 @@ package io.github.petty.tour.repository; +import io.github.petty.tour.dto.TourSummaryProjection; import io.github.petty.tour.entity.Content; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.math.BigDecimal; import java.util.List; +import java.util.Optional; @Repository public interface ContentRepository extends JpaRepository { List findByContentId(Long contentId); - @Query(""" - SELECT c FROM Content c - LEFT JOIN FETCH c.petTourInfo - """) /** * 주어진 contentId 목록에 해당하는 Content 및 연관된 모든 데이터(Cascade)를 삭제합니다. @@ -26,4 +28,37 @@ public interface ContentRepository extends JpaRepository { // 테스트용 20개 추출 List findTop20ByOrderByContentIdAsc(); + + // 1. 지역 기반 검색 (JPQL 사용 예시 - Optional 파라미터 처리) + @Query( + value = "SELECT c.contentId, c.title, c.addr1, c.contentTypeId, c.firstImage, c.cat1 FROM content c WHERE c.areaCode = :areaCode " + + "AND (:sigunguCode IS NULL OR c.sigunguCode = :sigunguCode) " + + "AND (:contentTypeId IS NULL OR c.contentTypeId = :contentTypeId)", + nativeQuery = true) + Page findByAreaCriteria(@Param("areaCode") Integer areaCode, + @Param("sigunguCode") Integer sigunguCode, + @Param("contentTypeId") Integer contentTypeId, + Pageable pageable); + + @Query( + value = + "SELECT c.contentid, c.title, c.addr1, c.contentTypeId, c.firstImage, c.cat1, " + + "ST_Distance_Sphere(c.location, ST_SRID(POINT(:lon, :lat), 4326)) as distance " + + "FROM content c " + + "WHERE ST_Distance_Sphere(c.location, ST_SRID(POINT(:lon, :lat), 4326)) <= :radius " + + "AND (:contentTypeId IS NULL OR c.contenttypeid = :contentTypeId) " + + "ORDER BY distance ASC", // Order by distance + countQuery = "SELECT count(*) FROM content c " + + "WHERE ST_Distance_Sphere(c.location, ST_SRID(POINT(:lon, :lat), 4326)) <= :radius " + + "AND (:contentTypeId IS NULL OR c.contenttypeid = :contentTypeId)", + nativeQuery = true) + Page findByLocationNative( // Return Object[] or a dedicated projection interface + @Param("lon") BigDecimal lon, + @Param("lat") BigDecimal lat, + @Param("radius") int radius, + @Param("contentTypeId") Integer contentTypeId, + Pageable pageable); + + @Query("SELECT c FROM Content c LEFT JOIN FETCH c.petTourInfo WHERE c.contentId = :contentId") + Optional findByIdWithPetInfo(@Param("contentId") Long contentId); } diff --git a/src/main/java/io/github/petty/tour/repository/SigunguRepository.java b/src/main/java/io/github/petty/tour/repository/SigunguRepository.java new file mode 100644 index 0000000..795a1d1 --- /dev/null +++ b/src/main/java/io/github/petty/tour/repository/SigunguRepository.java @@ -0,0 +1,15 @@ +package io.github.petty.tour.repository; + + +import io.github.petty.tour.entity.Sigungu; +import io.github.petty.tour.entity.SigunguId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SigunguRepository extends JpaRepository { // Entity is Sigungu, ID type is SigunguId + + List findByAreaCode(Integer areaCode); +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/tour/service/TourService.java b/src/main/java/io/github/petty/tour/service/TourService.java new file mode 100644 index 0000000..3485cfa --- /dev/null +++ b/src/main/java/io/github/petty/tour/service/TourService.java @@ -0,0 +1,53 @@ +package io.github.petty.tour.service; + +import io.github.petty.tour.dto.CodeNameDto; +import io.github.petty.tour.dto.DetailCommonDto; // Assuming this is the final name +import io.github.petty.tour.dto.TourSummaryDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.math.BigDecimal; +import java.util.List; + +public interface TourService { + + + /** + * 모든 시도(Area) 정보를 CodeNameDto 리스트로 조회 (이름순 정렬) + * @return List of CodeNameDto representing Areas. + */ + public List getAreas(Integer areaCode); + + + /** + * 콘텐츠 ID로 상세 정보를 조회합니다. + * @param contentId 조회할 콘텐츠 ID + * @return DetailCommonDto + * @throws ResourceNotFoundException // Define this custom exception + */ + DetailCommonDto getContentDetailById(Long contentId); + + /** + * 지역 코드를 기반으로 콘텐츠 목록을 검색합니다. + * @param areaCode 지역 코드 (필수) + * @param sigunguCode 시군구 코드 (선택) + * @param contentTypeId 콘텐츠 타입 ID (선택) + * @param pageable 페이징 정보 + * @return Page + */ + Page searchByArea(Integer areaCode, Integer sigunguCode, Integer contentTypeId, Pageable pageable); + + /** + * 위치(좌표 및 반경)를 기반으로 콘텐츠 목록을 검색합니다. + * @param mapX 경도 (longitude) + * @param mapY 위도 (latitude) + * @param radius 반경 (미터) + * @param contentTypeId 콘텐츠 타입 ID (선택) + * @param pageable 페이징 정보 (결과는 거리순으로 정렬될 수 있음) + * @return Page (거리 정보 포함될 수 있음) + */ + Page searchByLocation(BigDecimal mapX, BigDecimal mapY, Integer radius, Integer contentTypeId, Pageable pageable); + + + +} diff --git a/src/main/java/io/github/petty/tour/service/TourServiceImpl.java b/src/main/java/io/github/petty/tour/service/TourServiceImpl.java new file mode 100644 index 0000000..83ac015 --- /dev/null +++ b/src/main/java/io/github/petty/tour/service/TourServiceImpl.java @@ -0,0 +1,91 @@ +package io.github.petty.tour.service; + +import io.github.petty.tour.dto.*; // Import all required DTOs +import io.github.petty.tour.entity.*; // Import required Entities +import io.github.petty.tour.exception.ResourceNotFoundException; // Define this custom exception +import io.github.petty.tour.mapper.ContentMapper; // RECOMMENDED: Define a MapStruct mapper +import io.github.petty.tour.dto.TourSummaryProjection; +import io.github.petty.tour.repository.*; // Import required Repositories +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; // Important for JPA operations + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor // Lombok for constructor injection +public class TourServiceImpl implements TourService { + + private final AreaRepository areaRepository; + private final SigunguRepository sigunguRepository; + private final ContentRepository contentRepository; + private final ContentMapper contentMapper; + + + + @Transactional(readOnly = true) + public List getAreas(Integer areaCode) { + if (areaCode == null) { + log.debug("Fetching all area codes using Mapper"); + return contentMapper.areasToCodeNameDtos(areaRepository.findAll()); + } + log.debug("Fetching sigungu codes for areaCode: {} using Mapper", areaCode); + + return contentMapper.sigungusToCodeNameDtos(sigunguRepository.findByAreaCode(areaCode)); + } + + + + @Override + @Transactional(readOnly = true) + public DetailCommonDto getContentDetailById(Long contentId) { + log.debug("Fetching content detail for ID: {}", contentId); + + Content content = contentRepository.findByIdWithPetInfo(contentId) + .orElseThrow(() -> new ResourceNotFoundException("Content not found with id: " + contentId)); + + + // 매퍼를 사용하여 엔티티 (및 관련 엔티티)를 DTO로 변환합니다. + // @Transactional은 매핑 중에 관련 게으른 컬렉션을 로드 할 수 있도록합니다. + DetailCommonDto dto = contentMapper.contentToDetailCommonDto(content); + + + log.info("Successfully fetched content detail for ID: {}", contentId); + return dto; + } + + @Override + @Transactional(readOnly = true) + public Page searchByArea(Integer areaCode, Integer sigunguCode, Integer contentTypeId, Pageable pageable) { + log.debug("지역별 검색: areaCode={}, sigunguCode={}, contentTypeId={}, pageable={}", + areaCode, sigunguCode, contentTypeId, pageable); + + // 이와 같은 저장소 메소드가 존재한다고 가정합니다 (@Query 또는 Criteria API 사용) + Page contentPage = contentRepository.findByAreaCriteria(areaCode, sigunguCode, contentTypeId, pageable); + contentPage.getContent().forEach(content -> log.info("content: {}", content.getContentId())); + // Map Page to Page + return contentPage.map(contentMapper::projectionToTourSummaryDto); // Using MapStruct + } + + @Override + @Transactional(readOnly = true) + public Page searchByLocation(BigDecimal mapX, BigDecimal mapY, Integer radius, Integer contentTypeId, Pageable pageable) { + log.debug("Searching by location: mapX={}, mapY={}, radius={}, contentTypeId={}, pageable={}", + mapX, mapY, radius, contentTypeId, pageable); + + Page projectionPage = contentRepository.findByLocationNative(mapX, mapY, radius, contentTypeId, pageable); + + projectionPage.getContent().forEach(content -> log.info("content: {}", content.getContentId())); + + return projectionPage.map(contentMapper::projectionToTourSummaryDto); + } + + +} \ No newline at end of file diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..f2e9e29 --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,366 @@ +/* Keep your existing base styles (body, container, h1) */ +body { + font-family: "Noto Sans KR", sans-serif; /* Keep font */ + max-width: 900px; /* Adjust max-width if needed */ + margin: 20px auto; + padding: 0 15px; /* Add horizontal padding */ + background-color: #fdf7e4; + color: #333; +} + +.container { + background-color: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +h1 { + text-align: center; + margin-bottom: 30px; + color: #800000; /* Example color */ + font-weight: 600; +} + +h1 i { /* Style icon */ + margin-right: 10px; + color: #007bff; +} + + +/* Search Section & Tabs */ +.search-section { + margin-bottom: 30px; + padding: 20px; + background-color: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; +} + +.tabs { + display: flex; + gap: 5px; /* Reduced gap */ + margin-bottom: 20px; + border-bottom: 2px solid #dee2e6; +} + +.tab { + padding: 10px 18px; + background-color: #e9ecef; + border: 1px solid transparent; /* Keep structure */ + border-bottom: none; /* Remove bottom border here */ + border-radius: 6px 6px 0 0; /* Round top corners */ + cursor: pointer; + color: #495057; + font-weight: 500; + transition: background-color 0.2s ease, color 0.2s ease; + display: flex; /* Align icon and text */ + align-items: center; + gap: 8px; /* Space between icon and text */ +} + +.tab:hover { + background-color: #d8dde2; +} + +.tab.active { + background-color: #ffffff; /* Make active tab background white */ + color: #007bff; /* Active tab text color */ + border-color: #dee2e6 #dee2e6 #ffffff; /* Connect border */ + border-bottom: 2px solid #ffffff; /* Cover the bottom border line */ + position: relative; /* Position relative for margin adjustment */ + bottom: -2px; /* Pull tab slightly down to overlap border */ + font-weight: 600; +} + +.tab i { /* Icon styling */ + font-size: 0.9em; +} + +/* Search Forms */ +.search-box { + display: none; /* Hidden by default */ + padding-top: 15px; +} + +.search-box.active { + display: block; /* Show active form */ +} + +.form-row { + display: flex; + flex-wrap: wrap; /* Allow wrapping on smaller screens */ + gap: 15px; /* Space between columns */ + margin-bottom: 15px; +} + +.form-group { + flex: 1; /* Each group tries to take equal space */ + min-width: 180px; /* Minimum width before wrapping */ +} + +.form-group.button-group { + display: flex; + align-items: flex-end; /* Align button to bottom */ +} + +label { + display: block; + margin-bottom: 5px; + font-weight: 500; + font-size: 0.9em; + color: #555; +} + +input[type="text"], +input[type="number"], +select { + width: 100%; + padding: 10px 12px; /* Slightly larger padding */ + border: 1px solid #ced4da; + border-radius: 4px; + box-sizing: border-box; + font-size: 1em; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input:focus, +select:focus { + border-color: #80bdff; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +select[disabled] { + background-color: #e9ecef; + cursor: not-allowed; +} + + +/* Buttons */ +button { + padding: 10px 15px; + font-size: 1em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; + display: inline-flex; /* Align icon and text */ + align-items: center; + gap: 8px; /* Space between icon and text */ + justify-content: center; /* Center content */ +} + +button:hover { + opacity: 0.9; +} +button:active { + transform: translateY(1px); /* Simple press effect */ +} + +button.search-button { /* Main search button */ + background-color: #007bff; + color: white; + width: 100%; /* Make main search button full width */ + font-weight: 500; +} +button.search-button:hover { + background-color: #0056b3; +} + + +#getLocationBtn { + background-color: #6c757d; /* Secondary color */ + color: white; + width: 100%; /* Fill group width */ +} +#getLocationBtn:hover { + background-color: #5a6268; +} + +/* Loading Indicator */ +#loading { + display: none; /* Hidden initially */ + text-align: center; + padding: 30px 0; + color: #555; +} + +.spinner { /* Simple CSS spinner */ + border: 4px solid #f3f3f3; + border-top: 4px solid #007bff; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + display: inline-block; + margin-right: 10px; + vertical-align: middle; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + + +/* Results Area & Items */ +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* Responsive grid */ + gap: 20px; /* Space between cards */ + margin-top: 20px; +} + +.result-item.card { /* Styling result items as cards */ + border: 1px solid #e1e4e8; + border-radius: 6px; + background-color: #fff; + overflow: hidden; /* Clip image corners */ + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + transition: box-shadow 0.2s ease-in-out; +} +.result-item.card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.08); +} + + +.card-img-top { + width: 100%; + height: 180px; /* Fixed height for images */ + object-fit: cover; /* Cover the area, cropping if needed */ + background-color: #eee; /* Placeholder color */ +} +.img-placeholder { + width: 100%; + height: 180px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f8f9fa; + color: #adb5bd; + font-size: 0.9em; +} + +.card-body { + padding: 15px; +} + +.card-title { + font-size: 1.1em; + font-weight: 600; + margin-top: 0; + margin-bottom: 10px; + color: #333; +} + +.card-text { + font-size: 0.9em; + color: #555; + margin-bottom: 8px; + display: flex; /* Align icon and text */ + align-items: center; + gap: 6px; +} +.card-text i { /* Style icons in card text */ + color: #007bff; + width: 14px; /* Fixed width for alignment */ + text-align: center; +} + +.detail-button { /* Detail button within card */ + margin-top: 10px; + padding: 6px 12px; + font-size: 0.9em; + background-color: #17a2b8; /* Info color */ + color: white; +} +.detail-button:hover { + background-color: #138496; +} + + +.detail-section { + margin-top: 15px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 4px; + border-top: 1px solid #e9ecef; + font-size: 0.85em; /* Smaller font for details */ +} +.detail-section h6 { + font-weight: 600; + margin-top: 10px; + margin-bottom: 8px; + border-bottom: 1px solid #ddd; + padding-bottom: 4px; + display: flex; + align-items: center; + gap: 6px; +} +.detail-section h6 i { + color: #007bff; +} +.detail-section p { + margin-bottom: 6px; + line-height: 1.5; +} +.detail-section strong { + color: #333; +} + + +/* Pagination */ +#pagination { + margin-top: 30px; /* More space before pagination */ + padding-bottom: 20px; +} + +#loadMoreBtn { + background-color: #28a745; /* Success color */ + color: white; + padding: 10px 25px; + font-weight: 500; +} +#loadMoreBtn:hover { + background-color: #218838; +} + +/* Error Messages */ +.error-message, .no-results { + text-align: center; + padding: 20px; + color: #721c24; /* Danger text color */ + background-color: #f8d7da; /* Danger background */ + border: 1px solid #f5c6cb; + border-radius: 4px; + margin: 20px 0; +} +.no-results { /* Different style for no results */ + color: #0c5460; + background-color: #d1ecf1; + border-color: #bee5eb; +} + + +/* Responsive Adjustments (Example) */ +@media (max-width: 768px) { + .results-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + h1 { font-size: 1.8em;} +} + +@media (max-width: 576px) { + body { padding: 0 10px;} + .container { padding: 15px; } + h1 { font-size: 1.5em; margin-bottom: 20px;} + .tabs { flex-wrap: wrap; } /* Allow tabs to wrap */ + .tab { flex-grow: 1; text-align: center; } /* Make tabs fill width */ + .form-row { flex-direction: column; gap: 10px;} /* Stack form elements */ + .results-grid { + grid-template-columns: 1fr; /* Single column layout */ + gap: 15px; + } + .card-img-top, .img-placeholder { height: 160px; } +} \ No newline at end of file diff --git a/src/main/resources/static/js/search.js b/src/main/resources/static/js/search.js new file mode 100644 index 0000000..49381bc --- /dev/null +++ b/src/main/resources/static/js/search.js @@ -0,0 +1,337 @@ +const API_BASE_URL = '/api/v1/contents'; // Your backend API base path +const resultsDiv = document.getElementById('results'); +const loadingDiv = document.getElementById('loading'); +const loadMoreBtn = document.getElementById('loadMoreBtn'); +const areaSelect = document.getElementById('areaCode'); +const sigunguSelect = document.getElementById('sigunguCode'); + +let currentPage = 0; +let totalPages = 0; +let currentSearchType = 'area'; // 'area' or 'location' +let isLoading = false; +let currentSearchParameters = {}; // Store parameters for loadMore + +// --- Initialization --- +document.addEventListener('DOMContentLoaded', () => { + // Set initial active tab + switchSearchType('area'); + + // Add event listener for area code changes to potentially load sigungu codes + // (Requires a backend endpoint for sigungu codes) + areaSelect.addEventListener('change', (e) => { + const areaCode = e.target.value; + sigunguSelect.innerHTML = ''; // Clear previous options + sigunguSelect.disabled = true; + if (areaCode) { + loadSigunguCodes(areaCode); // Uncomment if you implement this + } + }); +}); + +// --- Tab Switching --- +function switchSearchType(type) { + currentSearchType = type; + document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('.search-box').forEach(box => box.classList.remove('active')); + + document.getElementById(`tab-${type}`).classList.add('active'); + document.getElementById(`${type}Search`).classList.add('active'); + + // Reset results when switching tabs + resultsDiv.innerHTML = ''; + loadMoreBtn.style.display = 'none'; + currentPage = 0; + totalPages = 0; +} + +// --- Loading Indicator --- +function showLoading(show) { + isLoading = show; + loadingDiv.style.display = show ? 'block' : 'none'; +} + +// --- Get Current Location --- +function getCurrentLocation() { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + document.getElementById('mapY').value = position.coords.latitude.toFixed(6); + document.getElementById('mapX').value = position.coords.longitude.toFixed(6); + }, + (error) => { + console.error("Error getting current location:", error); + alert("현재 위치를 가져올 수 없습니다. 직접 입력하거나 권한을 확인해주세요."); + }, + { enableHighAccuracy: true } // Optional: Improve accuracy + ); + } else { + alert("이 브라우저에서는 위치 정보 기능을 지원하지 않습니다."); + } +} + +// --- Fetch Data (Generic Helper) --- +async function fetchData(url) { + showLoading(true); + try { + const response = await fetch(url); + if (!response.ok) { + // Try to get error message from backend response body + let errorMsg = `Error: ${response.status} ${response.statusText}`; + try { + const errorData = await response.json(); + errorMsg = errorData.message || errorMsg; // Use backend message if available + } catch(e) { /* Ignore if response is not JSON */ } + throw new Error(errorMsg); + } + return await response.json(); + } catch (error) { + console.error("API Call Failed:", error); + displayError(`데이터 로딩 실패: ${error.message}`); + return null; // Indicate failure + } finally { + showLoading(false); + } +} + +// --- Search Functions --- +async function searchByArea(page = 0) { + const areaCode = areaSelect.value; + const sigunguCode = sigunguSelect.value; + // const contentTypeId = document.getElementById('areaContentTypeId')?.value; // Optional + + if (!areaCode) { + alert("지역을 선택해주세요."); + return; + } + + // Store parameters for 'Load More' + currentSearchParameters = { areaCode, sigunguCode }; // Add contentTypeId if used + currentSearchType = 'area'; // Ensure type is set + + let url = `${API_BASE_URL}/search/area?areaCode=${areaCode}`; + if (sigunguCode) { + url += `&sigunguCode=${sigunguCode}`; + } + url += `&page=${page}&size=10`; + // if (contentTypeId) { + // url += `&contentTypeId=${contentTypeId}`; + // } + + const data = await fetchData(url); + if (data) { + displayResults(data, page > 0); + } else { + if (page === 0) resultsDiv.innerHTML = ''; // Clear if initial search failed + loadMoreBtn.style.display = 'none'; + } +} + +async function searchByLocation(page = 0) { + const mapY = document.getElementById('mapY').value; + const mapX = document.getElementById('mapX').value; + const radius = document.getElementById('radius').value; + // const contentTypeId = document.getElementById('locContentTypeId')?.value; // Optional + + if (!mapY || !mapX) { + alert("위도와 경도를 입력하거나 현재 위치를 사용해주세요."); + return; + } + if (!radius) { + alert("검색 반경을 선택해주세요."); + return; + } + + // Store parameters for 'Load More' + currentSearchParameters = { mapX, mapY, radius }; // Add contentTypeId if used + currentSearchType = 'location'; // Ensure type is set + + let url = `${API_BASE_URL}/search/location?mapY=${mapY}&mapX=${mapX}&radius=${radius}&page=${page}&size=10`; + // if (contentTypeId) { + // url += `&contentTypeId=${contentTypeId}`; + // } + + const data = await fetchData(url); + if (data) { + displayResults(data, page > 0); + } else { + if (page === 0) resultsDiv.innerHTML = ''; // Clear if initial search failed + loadMoreBtn.style.display = 'none'; + } +} + +// --- Display Results --- +function displayResults(data, append = false) { + if (!append) { + resultsDiv.innerHTML = ''; // Clear previous results for a new search + } + + if (!data || !data.content || data.content.length === 0) { + if (!append) { // Only show 'no results' on the first page + resultsDiv.innerHTML = '

검색 결과가 없습니다.

'; + } + loadMoreBtn.style.display = 'none'; + return; + } + + // Update pagination info + currentPage = data.page.number; // Spring Pageable is 0-indexed + totalPages = data.page.totalPages; + + // Append new items + data.content.forEach(item => { + const itemDiv = document.createElement('div'); + itemDiv.className = 'result-item card'; // Use card class for better styling + itemDiv.innerHTML = ` + ${item.firstImage ? `${item.title || '이미지'}` : '
이미지 없음
'} +
+
${item.title || '이름 없음'}
+

${item.addr1 || '주소 정보 없음'}

+ ${item.distanceMeters ? `

약 ${Math.round(item.distanceMeters / 100) / 10}km

` : ''} + + +
+ `; + resultsDiv.appendChild(itemDiv); + + // Add event listener for the detail button + const detailButton = itemDiv.querySelector('.detail-button'); + detailButton.addEventListener('click', () => { + showDetail(item.contentId); + }); + }); + + // Show or hide 'Load More' button + if (currentPage < totalPages - 1) { + loadMoreBtn.style.display = 'block'; + } else { + loadMoreBtn.style.display = 'none'; + } +} + +// --- Load More Results --- +function loadMore() { + if (isLoading || currentPage >= totalPages - 1) { + return; // Prevent multiple clicks or loading beyond the last page + } + const nextPage = currentPage + 1; + if (currentSearchType === 'area') { + searchByArea(nextPage); + } else if (currentSearchType === 'location') { + searchByLocation(nextPage); + } +} + +// --- Show/Hide Detail --- +async function showDetail(contentId) { + const detailDiv = document.getElementById(`detail-${contentId}`); + const detailButton = detailDiv.previousElementSibling; // Get the button + + if (!detailDiv) return; + + // Toggle visibility + if (detailDiv.style.display === 'block') { + detailDiv.style.display = 'none'; + detailButton.textContent = '상세 정보'; // Reset button text + } else { + // Show loading in detail section + detailDiv.innerHTML = '

상세 정보 로딩 중...

'; + detailDiv.style.display = 'block'; + detailButton.textContent = '상세 정보 닫기'; // Change button text + + const url = `${API_BASE_URL}/${contentId}`; + const detailData = await fetchData(url); // Use fetchData for consistency + + if (detailData) { + renderDetail(detailDiv, detailData); + } else { + detailDiv.innerHTML = '

상세 정보를 불러오는데 실패했습니다.

'; + // Keep the div open to show the error + } + } +} + +// --- Render Detail Information --- +function renderDetail(detailDiv, detailInfo) { + if (!detailInfo) { + detailDiv.innerHTML = "

상세 정보가 없습니다.

"; + return; + } + + // Customize based on your DetailCommonDto structure + let detailHtml = `
상세 정보
`; + if (detailInfo.overview) + detailHtml += `

소개: ${detailInfo.overview}

`; // Assuming 'overview' field + + if (detailInfo.homepage) + detailHtml += `

홈페이지:

`; + + if (detailInfo.tel) + detailHtml += `

전화번호: ${detailInfo.tel}

`; // Assuming 'tel' field + if (detailInfo.telname) + detailHtml += `

담당자: ${detailInfo.telname}

`; // Assuming 'tel' field + + detailHtml += `
반려동물 정보
`; + // Add fields from your old UI.renderDetail, checking if they exist in detailInfo + const petFields = [ + { key: "acmpyPsblCpam", label: "동반 가능 동물" }, + { key: "acmpyTypeCd", label: "동반 유형" }, // Map code to text if needed + { key: "acmpyNeedMtr", label: "동반 필요 조건" }, + { key: "relaPosesFclty", label: "관련 편의 시설" }, + { key: "relaFrnshPrdlst", label: "관련 비품 목록" }, + { key: "relaRntlPrdlst", label: "관련 대여 상품" }, + { key: "relaPurcPrdlst", label: "관련 구매 상품" }, + { key: "relaAcdntRiskMtr", label: "사고 예방 사항" }, + { key: "etcAcmpyInfo", label: "기타 동반 정보" }, + ]; + + + petFields.forEach(field => { + const petValue = detailInfo.petTourInfo[field.key]; + if (petValue && petValue.trim() !== '') { + detailHtml += `

${field.label}: ${petValue}

`; + } + }); + + detailDiv.innerHTML = detailHtml; +} + +// --- Display Error --- +function displayError(message) { + // Display error in the results area or a dedicated error div + resultsDiv.innerHTML = `

${message}

`; + loadMoreBtn.style.display = 'none'; // Hide load more on error +} + + +// --- (Optional) Load Sigungu Codes --- +async function loadSigunguCodes(areaCode) { + // IMPORTANT: Requires a backend endpoint like /api/v1/contents/codes/sigungu?areaCode={areaCode} + const url = `${API_BASE_URL}/codes?areaCode=${areaCode}`; + showLoading(true); // Indicate loading sigungu + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load sigungu codes: ${response.status}`); + } + const sigunguData = await response.json(); // Assuming backend returns List with 'code' and 'name' fields + + sigunguSelect.innerHTML = ''; // Clear previous + if (sigunguData && sigunguData.length > 0) { + sigunguData.forEach(item => { + const option = document.createElement('option'); + option.value = item.code; // Use the correct field name from your DTO + option.textContent = item.name; // Use the correct field name from your DTO + sigunguSelect.appendChild(option); + }); + sigunguSelect.disabled = false; + } else { + sigunguSelect.disabled = true; + } + } catch (error) { + console.error("Failed to load Sigungu codes:", error); + sigunguSelect.disabled = true; + // Optionally display an error message to the user + } finally { + showLoading(false); // Hide loading indicator used for sigungu + } +} diff --git a/src/main/resources/templates/search.html b/src/main/resources/templates/search.html new file mode 100644 index 0000000..0b3e8a1 --- /dev/null +++ b/src/main/resources/templates/search.html @@ -0,0 +1,109 @@ + + + + + + 반려동물 동반여행 정보 검색 + + + +
+

반려동물 동반여행 정보

+
+ + +
+ + + + +
+ + + +
+
+ + + +
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/syncTest.html b/src/main/resources/templates/syncTest.html new file mode 100644 index 0000000..27c7af2 --- /dev/null +++ b/src/main/resources/templates/syncTest.html @@ -0,0 +1,12 @@ + + + + + 🐶 PETTY + + +
+ +
+ + \ No newline at end of file