Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
524e925
[feat] Add frontend UI for searching tour content (HTML/CSS/JS)
usn757 Apr 24, 2025
53c3de9
[feat] Add MapStruct/Lombok and Spatial dependencies
usn757 Apr 24, 2025
27737b1
[chore] Add configure Spring Data Web Support
usn757 Apr 24, 2025
1b27064
[feat] 투어 정보 검색 및 세부 사항에 대한 백엔드 API 구현, 위치 기반 공간 검색 기능 추가
usn757 Apr 24, 2025
14fba03
[feat] 투어 정보 검색 및 세부 사항에 대한 백엔드 API 구현, 위치 기반 공간 검색 기능 추가
usn757 Apr 24, 2025
360a3f1
[feat] 투어 정보 검색 및 세부 사항에 대한 백엔드 API 구현, 위치 기반 공간 검색 기능 추가
usn757 Apr 24, 2025
b892680
[feat] 투어 정보 검색 및 세부 사항에 대한 백엔드 API 구현, 위치 기반 공간 검색 기능 추가
usn757 Apr 24, 2025
fb7b8e6
[feat] Integrate MapStruct for Entity/DTO mapping
usn757 Apr 24, 2025
7bdf910
[feat] 검색 페이지 추가
usn757 Apr 24, 2025
c221be6
[chore] 스프링 스케줄링 지원 구성 추가
usn757 Apr 26, 2025
7027473
[feat] WebFlux 종속성 추가
usn757 Apr 26, 2025
3ba9a95
[feat] 투어 API 연결을 위한 구성 추가 (Properties, WebClient)
usn757 Apr 26, 2025
921ec97
[feat] 투어 API 응답의 DTOs 정의
usn757 Apr 26, 2025
2afcac7
[feat] 외부 API에서 데이터를 가져오기, WebClient로 구현
usn757 Apr 26, 2025
3df5bbc
[feat] DTO-엔티티 변환, MapStruct로 구현
usn757 Apr 26, 2025
be4333f
[feat] Custom MapStruct 변환 추가 (Date, Number, Point)
usn757 Apr 26, 2025
9a6dda7
[feat] 단일 데이터 동기화를위한 DateSyncProcessor 구현 (transactional delete-then-…
usn757 Apr 26, 2025
fc586fb
[feat] 일일 데이터 동기화 오케스트레이션 구현, Scheduling 및 동기화 날짜 계산 로직 추가
usn757 Apr 26, 2025
0dd795b
[feat] 수동 동기화 트리거 - endpoint 추가
usn757 Apr 26, 2025
0126414
[fix] 잘못된 @Query 삭제
usn757 Apr 26, 2025
0881753
[feat] 수동 DB 동기화 실행을 위한 UI 페이지 추가
usn757 Apr 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,28 @@ 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'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
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'
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/io/github/petty/IndexController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
6 changes: 4 additions & 2 deletions src/main/java/io/github/petty/PettyApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
241 changes: 241 additions & 0 deletions src/main/java/io/github/petty/dbsync/client/TourApiClient.java
Original file line number Diff line number Diff line change
@@ -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<List<PetTourSyncItemDto>> 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<ApiResponseDto<BodyDto<ItemsDto<PetTourSyncItemDto>>>>() {})
.flatMap(this::handleListResponse);

}

/**
* 공통 정보 조회
* @param contentId 콘텐츠 ID (필수)
* @return DetailCommonDto를 포함하는 Mono
*/
public Mono<DetailCommonDto> 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<ApiResponseDto<BodyDto<ItemsDto<DetailCommonDto>>>>() {})
.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<DetailIntroDto> 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<ApiResponseDto<BodyDto<ItemsDto<DetailIntroDto>>>>() {})
.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<List<DetailInfoDto>> 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<ApiResponseDto<BodyDto<ItemsDto<DetailInfoDto>>>>() {})
.flatMap(this::handleListResponse);
}

/**
* 이미지 정보 조회 (detailImage)
* @param contentId 콘텐츠 ID (필수)
* @return DetailImageDto 리스트를 포함하는 Mono
*/
public Mono<List<DetailImageDto>> 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<ApiResponseDto<BodyDto<ItemsDto<DetailImageDto>>>>() {})
.flatMap(this::handleListResponse);
}

/**
* 반려동물 동반 정보 조회 (detailPetTour)
* @param contentId 콘텐츠 ID (필수)
* @return DetailPetDto를 포함하는 Mono
*/
public Mono<DetailPetDto> 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<ApiResponseDto<BodyDto<ItemsDto<DetailPetDto>>>>() {})
.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<String, String> defaultParams = tourProperties.getDefaultParams();
if (defaultParams != null) {
defaultParams.forEach(uriBuilder::queryParam);
}
}


private <T> Mono<List<T>> handleListResponse(ApiResponseDto<BodyDto<ItemsDto<T>>> 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<T> 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));
}

}
16 changes: 16 additions & 0 deletions src/main/java/io/github/petty/dbsync/config/TourProperties.java
Original file line number Diff line number Diff line change
@@ -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<String, String> defaultParams; // _type, MobileOS, MobileApp
private String baseUrl;
private String serviceKey;
}
27 changes: 27 additions & 0 deletions src/main/java/io/github/petty/dbsync/config/WebClientConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String> runManualSync() {
log.info("===== 수동 동기화 요청 수신 =====");
try {
syncService.synchronizePetTourData();
log.info("===== 수동 동기화 요청 처리 완료 =====");
return ResponseEntity.ok("수동 동기화 작업이 성공적으로 시작/완료되었습니다."); // 실제 완료는 비동기일 수 있음
} catch (Exception e) {
log.error("!!!!! 수동 동기화 실행 중 오류 발생 !!!!!", e);
return ResponseEntity.internalServerError().body("수동 동기화 실행 중 오류 발생: " + e.getMessage());
}
}
}
16 changes: 16 additions & 0 deletions src/main/java/io/github/petty/dbsync/dto/ApiResponseDto.java
Original file line number Diff line number Diff line change
@@ -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> { // T는 Body DTO 타입
@JsonProperty("response")
private ResponseDto<T> response;
}

Loading