🔖 Batch Release Version : 1.0.0
✨ S&P 수집 배치 Version 1.0.0 (정규화 이전)
* AIS
* Movements
* Events
* Risk&Compliance
* PSC
* Ships
* Facilities
This commit is contained in:
부모
1241a71d31
커밋
0c48b9f1b1
14
pom.xml
14
pom.xml
@ -111,6 +111,20 @@
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine Cache -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>3.1.8</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JTS (Java Topology Suite) - 공간 연산 라이브러리 -->
|
||||
<dependency>
|
||||
<groupId>org.locationtech.jts</groupId>
|
||||
<artifactId>jts-core</artifactId>
|
||||
<version>1.19.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Test Dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@ -2,10 +2,12 @@ package com.snp.batch;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan
|
||||
public class SnpBatchApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@ -0,0 +1,149 @@
|
||||
package com.snp.batch.api.logging;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||
import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* API 요청/응답 로깅 필터
|
||||
*
|
||||
* 로그 파일: logs/api-access.log
|
||||
* 기록 내용: 요청 IP, HTTP Method, URI, 파라미터, 응답 상태, 처리 시간
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ApiAccessLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final int MAX_PAYLOAD_LENGTH = 1000;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
// 정적 리소스 및 actuator 제외
|
||||
String uri = request.getRequestURI();
|
||||
if (shouldSkip(uri)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 요청 래핑 (body 읽기용)
|
||||
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
|
||||
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
|
||||
|
||||
String requestId = UUID.randomUUID().toString().substring(0, 8);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
filterChain.doFilter(requestWrapper, responseWrapper);
|
||||
} finally {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
logRequest(requestId, requestWrapper, responseWrapper, duration);
|
||||
responseWrapper.copyBodyToResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldSkip(String uri) {
|
||||
return uri.startsWith("/actuator")
|
||||
|| uri.startsWith("/css")
|
||||
|| uri.startsWith("/js")
|
||||
|| uri.startsWith("/images")
|
||||
|| uri.startsWith("/favicon")
|
||||
|| uri.endsWith(".html")
|
||||
|| uri.endsWith(".css")
|
||||
|| uri.endsWith(".js")
|
||||
|| uri.endsWith(".ico");
|
||||
}
|
||||
|
||||
private void logRequest(String requestId,
|
||||
ContentCachingRequestWrapper request,
|
||||
ContentCachingResponseWrapper response,
|
||||
long duration) {
|
||||
|
||||
String clientIp = getClientIp(request);
|
||||
String method = request.getMethod();
|
||||
String uri = request.getRequestURI();
|
||||
String queryString = request.getQueryString();
|
||||
int status = response.getStatus();
|
||||
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append(String.format("[%s] %s %s %s",
|
||||
requestId, clientIp, method, uri));
|
||||
|
||||
// Query String
|
||||
if (queryString != null && !queryString.isEmpty()) {
|
||||
logMessage.append("?").append(truncate(queryString, 200));
|
||||
}
|
||||
|
||||
// Request Body (POST/PUT/PATCH)
|
||||
if (isBodyRequest(method)) {
|
||||
String body = getRequestBody(request);
|
||||
if (!body.isEmpty()) {
|
||||
logMessage.append(" | body=").append(truncate(body, MAX_PAYLOAD_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
// Response
|
||||
logMessage.append(String.format(" | status=%d | %dms", status, duration));
|
||||
|
||||
// 상태에 따른 로그 레벨
|
||||
if (status >= 500) {
|
||||
log.error(logMessage.toString());
|
||||
} else if (status >= 400) {
|
||||
log.warn(logMessage.toString());
|
||||
} else {
|
||||
log.info(logMessage.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 여러 IP가 있는 경우 첫 번째만
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
private boolean isBodyRequest(String method) {
|
||||
return "POST".equalsIgnoreCase(method)
|
||||
|| "PUT".equalsIgnoreCase(method)
|
||||
|| "PATCH".equalsIgnoreCase(method);
|
||||
}
|
||||
|
||||
private String getRequestBody(ContentCachingRequestWrapper request) {
|
||||
byte[] content = request.getContentAsByteArray();
|
||||
if (content.length == 0) {
|
||||
return "";
|
||||
}
|
||||
return new String(content, StandardCharsets.UTF_8)
|
||||
.replaceAll("\\s+", " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private String truncate(String str, int maxLength) {
|
||||
if (str == null) return "";
|
||||
if (str.length() <= maxLength) return str;
|
||||
return str.substring(0, maxLength) + "...";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.snp.batch.common.batch.config;
|
||||
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* 기존 단일 스텝 기능을 유지하면서 멀티 스텝 구성을 지원하는 확장 클래스
|
||||
*/
|
||||
public abstract class BaseMultiStepJobConfig<I, O> extends BaseJobConfig<I, O> {
|
||||
|
||||
public BaseMultiStepJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
|
||||
super(jobRepository, transactionManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 클래스에서 멀티 스텝 흐름을 정의합니다.
|
||||
*/
|
||||
protected abstract Job createJobFlow(JobBuilder jobBuilder);
|
||||
|
||||
/**
|
||||
* 부모의 job() 메서드를 오버라이드하여 멀티 스텝 흐름을 태웁니다.
|
||||
*/
|
||||
@Override
|
||||
public Job job() {
|
||||
JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository);
|
||||
configureJob(jobBuilder); // 기존 리스너 등 설정 유지
|
||||
|
||||
return createJobFlow(jobBuilder);
|
||||
}
|
||||
|
||||
// 단일 스텝용 Reader/Processor/Writer는 사용하지 않을 경우
|
||||
// 기본적으로 null이나 예외를 던지도록 구현하여 구현 부담을 줄일 수 있습니다.
|
||||
@Override
|
||||
protected ItemReader<I> createReader() { return null; }
|
||||
@Override
|
||||
protected ItemProcessor<I, O> createProcessor() { return null; }
|
||||
@Override
|
||||
protected ItemWriter<O> createWriter() { return null; }
|
||||
}
|
||||
@ -55,7 +55,7 @@ public abstract class BaseProcessor<I, O> implements ItemProcessor<I, O> {
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("데이터 처리 중: {}", item);
|
||||
// log.debug("데이터 처리 중: {}", item);
|
||||
return processItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.snp.batch.common.batch.reader;
|
||||
|
||||
import com.snp.batch.global.model.BatchApiLog;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepExecution;
|
||||
import org.springframework.batch.core.annotation.BeforeStep;
|
||||
@ -7,8 +9,13 @@ import org.springframework.batch.item.ExecutionContext;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import org.springframework.web.util.UriBuilder;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.LocalDateTime;
|
||||
@ -72,12 +79,180 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
|
||||
private int totalApiCalls = 0;
|
||||
private int completedApiCalls = 0;
|
||||
|
||||
// Batch Execution Id
|
||||
private Long jobExecutionId; // 현재 Job 실행 ID
|
||||
private Long stepExecutionId; // 현재 Step 실행 ID
|
||||
/**
|
||||
* 스프링 배치가 Step을 시작할 때 실행 ID를 주입해줍니다.
|
||||
*/
|
||||
public void setExecutionIds(Long jobExecutionId, Long stepExecutionId) {
|
||||
this.jobExecutionId = jobExecutionId;
|
||||
this.stepExecutionId = stepExecutionId;
|
||||
}
|
||||
/**
|
||||
* 기본 생성자 (WebClient 없이 사용 - Mock 데이터용)
|
||||
*/
|
||||
protected BaseApiReader() {
|
||||
this.webClient = null;
|
||||
}
|
||||
/**
|
||||
* API 호출 및 로그 적재 통합 메서드
|
||||
* Response Json 구조 : [...]
|
||||
*/
|
||||
protected <R> List<R> executeListApiCall(
|
||||
String baseUrl,
|
||||
String path,
|
||||
Map<String, String> params,
|
||||
ParameterizedTypeReference<List<R>> typeReference,
|
||||
BatchApiLogService logService) {
|
||||
|
||||
// 1. 전체 URI 생성 (로그용)
|
||||
MultiValueMap<String, String> multiValueParams = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach((key, value) ->
|
||||
multiValueParams.put(key, Collections.singletonList(value))
|
||||
);
|
||||
}
|
||||
|
||||
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||
.path(path)
|
||||
.queryParams(multiValueParams)
|
||||
.build()
|
||||
.toUriString();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
int statusCode = 200;
|
||||
String errorMessage = null;
|
||||
Long responseSize = 0L;
|
||||
|
||||
try {
|
||||
log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri);
|
||||
|
||||
List<R> result = webClient.get()
|
||||
.uri(uriBuilder -> {
|
||||
uriBuilder.path(path);
|
||||
if (params != null) params.forEach(uriBuilder::queryParam);
|
||||
return uriBuilder.build();
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(typeReference)
|
||||
.block();
|
||||
|
||||
responseSize = (result != null) ? (long) result.size() : 0L;
|
||||
return result;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
// API 서버에서 응답은 왔으나 에러인 경우 (4xx, 5xx)
|
||||
statusCode = e.getStatusCode().value();
|
||||
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// 네트워크 오류, 타임아웃 등 기타 예외
|
||||
statusCode = 500;
|
||||
errorMessage = String.format("System Error: %s", e.getMessage());
|
||||
throw e;
|
||||
} finally {
|
||||
// 성공/실패 여부와 관계없이 무조건 로그 저장
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
logService.saveLog(BatchApiLog.builder()
|
||||
.apiRequestLocation(getReaderName())
|
||||
.requestUri(fullUri)
|
||||
.httpMethod("GET")
|
||||
.statusCode(statusCode)
|
||||
.responseTimeMs(duration)
|
||||
.responseCount(responseSize)
|
||||
.errorMessage(errorMessage)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.jobExecutionId(this.jobExecutionId) // 추가
|
||||
.stepExecutionId(this.stepExecutionId) // 추가
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 호출 및 로그 적재 통합 메서드
|
||||
* Response Json 구조 : { "data": [...] }
|
||||
*/
|
||||
protected <R> R executeSingleApiCall(
|
||||
String baseUrl,
|
||||
String path,
|
||||
Map<String, String> params,
|
||||
ParameterizedTypeReference<R> typeReference,
|
||||
BatchApiLogService logService,
|
||||
Function<R, Long> sizeExtractor) { // 사이즈 추출 함수 추가
|
||||
|
||||
// 1. 전체 URI 생성 (로그용)
|
||||
MultiValueMap<String, String> multiValueParams = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach((key, value) ->
|
||||
multiValueParams.put(key, Collections.singletonList(value))
|
||||
);
|
||||
}
|
||||
|
||||
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||
.path(path)
|
||||
.queryParams(multiValueParams)
|
||||
.build()
|
||||
.toUriString();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
int statusCode = 200;
|
||||
String errorMessage = null;
|
||||
R result = null;
|
||||
|
||||
try {
|
||||
log.info("[{}] Single API 요청 시작: {}", getReaderName(), fullUri);
|
||||
|
||||
result = webClient.get()
|
||||
.uri(uriBuilder -> {
|
||||
uriBuilder.path(path);
|
||||
if (params != null) params.forEach(uriBuilder::queryParam);
|
||||
return uriBuilder.build();
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(typeReference)
|
||||
.block();
|
||||
|
||||
return result;
|
||||
|
||||
} catch (WebClientResponseException e) {
|
||||
statusCode = e.getStatusCode().value();
|
||||
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
statusCode = 500;
|
||||
errorMessage = String.format("System Error: %s", e.getMessage());
|
||||
throw e;
|
||||
} finally {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 2. 주입받은 함수를 통해 데이터 건수(size) 계산
|
||||
long size = 0L;
|
||||
if (result != null && sizeExtractor != null) {
|
||||
try {
|
||||
size = sizeExtractor.apply(result);
|
||||
} catch (Exception e) {
|
||||
log.warn("[{}] 사이즈 추출 중 오류 발생: {}", getReaderName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 로그 저장 (api_request_location, response_size 반영)
|
||||
logService.saveLog(BatchApiLog.builder()
|
||||
.apiRequestLocation(getReaderName())
|
||||
.jobExecutionId(this.jobExecutionId)
|
||||
.stepExecutionId(this.stepExecutionId)
|
||||
.requestUri(fullUri)
|
||||
.httpMethod("GET")
|
||||
.statusCode(statusCode)
|
||||
.responseTimeMs(duration)
|
||||
.responseCount(size)
|
||||
.errorMessage(errorMessage)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* WebClient를 주입받는 생성자 (실제 API 연동용)
|
||||
@ -87,7 +262,7 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
|
||||
protected BaseApiReader(WebClient webClient) {
|
||||
this.webClient = webClient;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Step 실행 전 초기화 및 API 정보 저장
|
||||
* Spring Batch가 자동으로 StepExecution을 주입하고 이 메서드를 호출함
|
||||
@ -98,6 +273,9 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
|
||||
public void saveApiInfoToContext(StepExecution stepExecution) {
|
||||
this.stepExecution = stepExecution;
|
||||
|
||||
// Reader 상태 초기화 (Job 재실행 시 필수)
|
||||
resetReaderState();
|
||||
|
||||
// API 정보를 StepExecutionContext에 저장
|
||||
ExecutionContext context = stepExecution.getExecutionContext();
|
||||
|
||||
@ -140,6 +318,48 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader 상태 초기화
|
||||
* Job 재실행 시 이전 실행의 상태를 클리어하여 새로 데이터를 읽을 수 있도록 함
|
||||
*/
|
||||
private void resetReaderState() {
|
||||
// Chunk 모드 상태 초기화
|
||||
this.currentBatch = null;
|
||||
this.initialized = false;
|
||||
|
||||
// Legacy 모드 상태 초기화
|
||||
this.legacyDataList = null;
|
||||
this.legacyNextIndex = 0;
|
||||
|
||||
// 통계 초기화
|
||||
this.totalApiCalls = 0;
|
||||
this.completedApiCalls = 0;
|
||||
|
||||
// 하위 클래스 상태 초기화 훅 호출
|
||||
resetCustomState();
|
||||
|
||||
log.debug("[{}] Reader 상태 초기화 완료", getReaderName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 클래스 커스텀 상태 초기화 훅
|
||||
* Chunk 모드에서 사용하는 currentBatchIndex, allImoNumbers 등의 필드를 초기화할 때 오버라이드
|
||||
*
|
||||
* 예시:
|
||||
* <pre>
|
||||
* @Override
|
||||
* protected void resetCustomState() {
|
||||
* this.currentBatchIndex = 0;
|
||||
* this.allImoNumbers = null;
|
||||
* this.dbMasterHashes = null;
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
protected void resetCustomState() {
|
||||
// 기본 구현: 아무것도 하지 않음
|
||||
// 하위 클래스에서 필요 시 오버라이드
|
||||
}
|
||||
|
||||
/**
|
||||
* API 호출 통계 업데이트
|
||||
*/
|
||||
@ -209,21 +429,42 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
|
||||
}
|
||||
|
||||
// currentBatch가 비어있으면 다음 배치 로드
|
||||
if (currentBatch == null || !currentBatch.hasNext()) {
|
||||
/*if (currentBatch == null || !currentBatch.hasNext()) {
|
||||
List<T> nextBatch = fetchNextBatch();
|
||||
|
||||
// 더 이상 데이터가 없으면 종료
|
||||
if (nextBatch == null || nextBatch.isEmpty()) {
|
||||
// if (nextBatch == null || nextBatch.isEmpty()) {
|
||||
if (nextBatch == null ) {
|
||||
afterFetch(null);
|
||||
log.info("[{}] 모든 배치 처리 완료", getReaderName());
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Iterator 갱신
|
||||
currentBatch = nextBatch.iterator();
|
||||
log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size());
|
||||
}*/
|
||||
// currentBatch가 비어있으면 다음 배치 로드
|
||||
while (currentBatch == null || !currentBatch.hasNext()) {
|
||||
List<T> nextBatch = fetchNextBatch();
|
||||
|
||||
if (nextBatch == null) { // 진짜 종료
|
||||
afterFetch(null);
|
||||
log.info("[{}] 모든 배치 처리 완료", getReaderName());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nextBatch.isEmpty()) { // emptyList면 다음 batch를 시도
|
||||
log.warn("[{}] 빈 배치 수신 → 다음 배치 재요청", getReaderName());
|
||||
continue; // while 반복문으로 다시 fetch
|
||||
}
|
||||
|
||||
currentBatch = nextBatch.iterator();
|
||||
log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size());
|
||||
}
|
||||
|
||||
|
||||
// Iterator에서 1건씩 반환
|
||||
return currentBatch.next();
|
||||
}
|
||||
|
||||
@ -13,14 +13,44 @@ public class JsonChangeDetector {
|
||||
// 해시 비교에서 제외할 필드 목록 (DataSetVersion 등)
|
||||
// 이 목록은 모든 JSON 계층에 걸쳐 적용됩니다.
|
||||
private static final java.util.Set<String> EXCLUDE_KEYS =
|
||||
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDate", "LastUpdateDateTime");
|
||||
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime");
|
||||
|
||||
private static final Map<String, String> LIST_SORT_KEYS = Map.of(
|
||||
// List 필드명 // 정렬 기준 키
|
||||
"OwnerHistory" ,"Sequence", // OwnerHistory는 Sequence를 기준으로 정렬
|
||||
"SurveyDatesHistoryUnique" , "SurveyDate" // SurveyDatesHistoryUnique는 SurveyDate를 기준으로 정렬
|
||||
// 추가적인 List/Array 필드가 있다면 여기에 추가
|
||||
);
|
||||
// =========================================================================
|
||||
// ✅ LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
|
||||
// =========================================================================
|
||||
private static final Map<String, String> LIST_SORT_KEYS;
|
||||
static {
|
||||
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
|
||||
Map<String, String> map = new HashMap<>();
|
||||
// List 필드명 // 정렬 기준 복합 키 (JSON 필드명, 쉼표로 구분)
|
||||
map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence");
|
||||
map.put("CrewList", "LRNO,Shipname,Nationality");
|
||||
map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode");
|
||||
map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence");
|
||||
map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence");
|
||||
map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence");
|
||||
map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode");
|
||||
map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode");
|
||||
map.put("NameHistory", "Sequence,EffectiveDate");
|
||||
map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence");
|
||||
map.put("PandIHistory", "PandIClubCode,EffectiveDate");
|
||||
map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo");
|
||||
map.put("IceClass", "IceClassCode");
|
||||
map.put("SafetyManagementCertificateHistory", "Sequence");
|
||||
map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence");
|
||||
map.put("SurveyDatesHistory", "ClassSocietyCode");
|
||||
map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType");
|
||||
map.put("SisterShipLinks", "LinkedLRNO");
|
||||
map.put("StatusHistory", "Sequence,StatusCode,StatusDate");
|
||||
map.put("SpecialFeature", "Sequence,SpecialFeatureCode");
|
||||
map.put("Thrusters", "Sequence");
|
||||
map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status");
|
||||
map.put("CompanyComplianceDetails", "OwCode");
|
||||
map.put("CompanyVesselRelationships", "LRNO");
|
||||
map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate");
|
||||
|
||||
LIST_SORT_KEYS = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. JSON 문자열을 정렬 및 필터링된 Map으로 변환하는 핵심 로직
|
||||
@ -90,14 +120,16 @@ public class JsonChangeDetector {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 🔑 List 필드명에 따른 순서 정렬 로직 (추가된 핵심 로직)
|
||||
// 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정된 핵심 로직)
|
||||
String listFieldName = entry.getKey();
|
||||
String sortKey = LIST_SORT_KEYS.get(listFieldName);
|
||||
String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 키 문자열
|
||||
|
||||
if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
|
||||
// 복합 키 문자열을 개별 키 배열로 분리
|
||||
final String[] sortKeys = sortKeysString.split(",");
|
||||
|
||||
if (sortKey != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
|
||||
// Map 요소를 가진 리스트인 경우에만 정렬 실행
|
||||
try {
|
||||
// 정렬 기준 키를 사용하여 Comparator를 생성
|
||||
Collections.sort(filteredList, new Comparator<Object>() {
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
@ -105,22 +137,45 @@ public class JsonChangeDetector {
|
||||
Map<String, Object> map1 = (Map<String, Object>) o1;
|
||||
Map<String, Object> map2 = (Map<String, Object>) o2;
|
||||
|
||||
// 정렬 기준 키(sortKey)의 값을 가져와 비교
|
||||
// 복합 키(sortKeys)를 순서대로 순회하며 비교
|
||||
for (String rawSortKey : sortKeys) {
|
||||
// 키의 공백 제거
|
||||
String sortKey = rawSortKey.trim();
|
||||
|
||||
Object key1 = map1.get(sortKey);
|
||||
Object key2 = map2.get(sortKey);
|
||||
|
||||
if (key1 == null || key2 == null) {
|
||||
// 키 값이 null인 경우, Map의 전체 문자열로 비교 (안전장치)
|
||||
return map1.toString().compareTo(map2.toString());
|
||||
// null 값 처리 로직
|
||||
if (key1 == null && key2 == null) {
|
||||
continue; // 두 값이 동일하므로 다음 키로 이동
|
||||
}
|
||||
if (key1 == null) {
|
||||
// key1이 null이고 key2는 null이 아니면, key2가 더 크다고 (뒤 순서) 간주하고 1 반환
|
||||
return 1;
|
||||
}
|
||||
if (key2 == null) {
|
||||
// key2가 null이고 key1은 null이 아니면, key1이 더 크다고 (뒤 순서) 간주하고 -1 반환
|
||||
return -1;
|
||||
}
|
||||
|
||||
// String 타입으로 변환하여 비교 (Date, Number 타입도 대부분 String으로 처리 가능)
|
||||
return key1.toString().compareTo(key2.toString());
|
||||
// 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능)
|
||||
int comparisonResult = key1.toString().compareTo(key2.toString());
|
||||
|
||||
// 현재 키에서 순서가 결정되면 즉시 반환
|
||||
if (comparisonResult != 0) {
|
||||
return comparisonResult;
|
||||
}
|
||||
// comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함
|
||||
}
|
||||
|
||||
// 모든 키를 비교해도 동일한 경우
|
||||
// 이 경우 두 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다.
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage());
|
||||
// 정렬 실패 시 원래 순서 유지
|
||||
// 정렬 실패 시 원래 순서 유지 (filteredList 상태 유지)
|
||||
}
|
||||
}
|
||||
sortedMap.put(key, filteredList);
|
||||
@ -132,7 +187,6 @@ public class JsonChangeDetector {
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// 2. 해시 생성 로직
|
||||
// =========================================================================
|
||||
|
||||
24
src/main/java/com/snp/batch/global/config/AsyncConfig.java
Normal file
24
src/main/java/com/snp/batch/global/config/AsyncConfig.java
Normal file
@ -0,0 +1,24 @@
|
||||
package com.snp.batch.global.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync // 비동기 기능 활성화
|
||||
public class AsyncConfig {
|
||||
|
||||
@Bean(name = "apiLogExecutor")
|
||||
public Executor apiLogExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2); // 기본 스레드 수
|
||||
executor.setMaxPoolSize(5); // 최대 스레드 수
|
||||
executor.setQueueCapacity(500); // 대기 큐 크기
|
||||
executor.setThreadNamePrefix("ApiLogThread-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@ -29,9 +29,13 @@ public class MaritimeApiWebClientConfig {
|
||||
@Value("${app.batch.ship-api.url}")
|
||||
private String maritimeApiUrl;
|
||||
|
||||
@Value("https://aisapi.maritime.spglobal.com")
|
||||
@Value("${app.batch.ais-api.url}")
|
||||
private String maritimeAisApiUrl;
|
||||
|
||||
@Value("${app.batch.webservice-api.url}")
|
||||
private String maritimeServiceApiUrl;
|
||||
|
||||
|
||||
@Value("${app.batch.ship-api.username}")
|
||||
private String maritimeApiUsername;
|
||||
|
||||
@ -60,7 +64,7 @@ public class MaritimeApiWebClientConfig {
|
||||
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||
.codecs(configurer -> configurer
|
||||
.defaultCodecs()
|
||||
.maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
|
||||
.maxInMemorySize(100 * 1024 * 1024)) // 30MB 버퍼
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -76,7 +80,23 @@ public class MaritimeApiWebClientConfig {
|
||||
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||
.codecs(configurer -> configurer
|
||||
.defaultCodecs()
|
||||
.maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
|
||||
.maxInMemorySize(50 * 1024 * 1024)) // 50MB 버퍼 (AIS GetTargets 응답 ~20MB+)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean(name = "maritimeServiceApiWebClient")
|
||||
public WebClient maritimeServiceApiWebClient(){
|
||||
log.info("========================================");
|
||||
log.info("Maritime AIS API WebClient 생성");
|
||||
log.info("Base URL: {}", maritimeServiceApiUrl);
|
||||
log.info("========================================");
|
||||
|
||||
return WebClient.builder()
|
||||
.baseUrl(maritimeServiceApiUrl)
|
||||
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||
.codecs(configurer -> configurer
|
||||
.defaultCodecs()
|
||||
.maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,20 +30,29 @@ public class SwaggerConfig {
|
||||
@Value("${server.port:8081}")
|
||||
private int serverPort;
|
||||
|
||||
@Value("${server.servlet.context-path:}")
|
||||
private String contextPath;
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(apiInfo())
|
||||
.servers(List.of(
|
||||
new Server()
|
||||
.url("http://localhost:" + serverPort)
|
||||
.url("http://localhost:" + serverPort + contextPath)
|
||||
.description("로컬 개발 서버"),
|
||||
new Server()
|
||||
.url("http://211.208.115.83:" + serverPort)
|
||||
.url("http://10.26.252.39:" + serverPort + contextPath)
|
||||
.description("로컬 개발 서버"),
|
||||
new Server()
|
||||
.url("http://211.208.115.83:" + serverPort + contextPath)
|
||||
.description("중계 서버"),
|
||||
new Server()
|
||||
.url("https://api.snp-batch.com")
|
||||
.description("운영 서버 (예시)")
|
||||
.url("http://10.187.58.58:" + serverPort + contextPath)
|
||||
.description("운영 서버"),
|
||||
new Server()
|
||||
.url("https://mda.kcg.go.kr" + contextPath)
|
||||
.description("운영 서버 프록시")
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -1,20 +1,26 @@
|
||||
package com.snp.batch.global.controller;
|
||||
|
||||
import com.snp.batch.global.dto.JobExecutionDto;
|
||||
import com.snp.batch.global.dto.JobLaunchRequest;
|
||||
import com.snp.batch.global.dto.ScheduleRequest;
|
||||
import com.snp.batch.global.dto.ScheduleResponse;
|
||||
import com.snp.batch.service.BatchService;
|
||||
import com.snp.batch.service.ScheduleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.Explode;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterStyle;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springdoc.core.annotations.ParameterObject;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -56,6 +62,39 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "작업 실행 성공"),
|
||||
@ApiResponse(responseCode = "500", description = "작업 실행 실패")
|
||||
})
|
||||
@PostMapping("/jobs/{jobName}/executeJobTest")
|
||||
public ResponseEntity<Map<String, Object>> executeJobTest(
|
||||
@Parameter( description = "실행할 배치 작업 이름", required = true,example = "sampleProductImportJob")
|
||||
@PathVariable String jobName,
|
||||
@ParameterObject JobLaunchRequest request
|
||||
) {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
if (request.getStartDate() != null) params.put("startDate", request.getStartDate());
|
||||
if (request.getStopDate() != null) params.put("stopDate", request.getStopDate());
|
||||
|
||||
log.info("Executing job: {} with params: {}", jobName, params);
|
||||
|
||||
try {
|
||||
Long executionId = batchService.executeJob(jobName, params);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Job started successfully",
|
||||
"executionId", executionId
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing job: {}", jobName, e);
|
||||
return ResponseEntity.internalServerError().body(Map.of(
|
||||
"success", false,
|
||||
"message", "Failed to start job: " + e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "배치 작업 목록 조회", description = "등록된 모든 배치 작업 목록을 조회합니다")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "조회 성공")
|
||||
@ -178,7 +217,7 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/schedules/{jobName}")
|
||||
@PostMapping("/schedules/{jobName}/update")
|
||||
public ResponseEntity<Map<String, Object>> updateSchedule(
|
||||
@PathVariable String jobName,
|
||||
@RequestBody Map<String, String> request) {
|
||||
@ -206,7 +245,7 @@ public class BatchController {
|
||||
@ApiResponse(responseCode = "200", description = "삭제 성공"),
|
||||
@ApiResponse(responseCode = "500", description = "삭제 실패")
|
||||
})
|
||||
@DeleteMapping("/schedules/{jobName}")
|
||||
@PostMapping("/schedules/{jobName}/delete")
|
||||
public ResponseEntity<Map<String, Object>> deleteSchedule(
|
||||
@Parameter(description = "배치 작업 이름", required = true)
|
||||
@PathVariable String jobName) {
|
||||
@ -226,7 +265,7 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
@PatchMapping("/schedules/{jobName}/toggle")
|
||||
@PostMapping("/schedules/{jobName}/toggle")
|
||||
public ResponseEntity<Map<String, Object>> toggleSchedule(
|
||||
@PathVariable String jobName,
|
||||
@RequestBody Map<String, Boolean> request) {
|
||||
|
||||
16
src/main/java/com/snp/batch/global/dto/JobLaunchRequest.java
Normal file
16
src/main/java/com/snp/batch/global/dto/JobLaunchRequest.java
Normal file
@ -0,0 +1,16 @@
|
||||
package com.snp.batch.global.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class JobLaunchRequest {
|
||||
@Schema(description = "조회 시작일 (ISO 8601)", example = "2023-12-01T00:00:00Z")
|
||||
private String startDate;
|
||||
|
||||
@Schema(description = "조회 종료일 (ISO 8601)", example = "2023-12-02T00:00:00Z")
|
||||
private String stopDate;
|
||||
}
|
||||
46
src/main/java/com/snp/batch/global/model/BatchApiLog.java
Normal file
46
src/main/java/com/snp/batch/global/model/BatchApiLog.java
Normal file
@ -0,0 +1,46 @@
|
||||
package com.snp.batch.global.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "batch_api_log", schema = "snp_data")
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class BatchApiLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY) // PostgreSQL BIGSERIAL과 매핑
|
||||
private Long logId;
|
||||
|
||||
@Column(name = "api_request_location") // job_name에서 변경
|
||||
private String apiRequestLocation;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String requestUri;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String httpMethod;
|
||||
|
||||
private Integer statusCode;
|
||||
|
||||
private Long responseTimeMs;
|
||||
|
||||
@Column(name = "response_count")
|
||||
private Long responseCount;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@CreationTimestamp // 엔티티가 생성될 때 자동으로 시간 설정
|
||||
@Column(updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private Long jobExecutionId; // 추가
|
||||
private Long stepExecutionId; // 추가
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.snp.batch.global.model;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "BATCH_LAST_EXECUTION")
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class BatchLastExecution {
|
||||
@Id
|
||||
@Column(name = "API_KEY", length = 50)
|
||||
private String apiKey;
|
||||
|
||||
@Column(name = "LAST_SUCCESS_DATE", nullable = false)
|
||||
private LocalDateTime lastSuccessDate;
|
||||
|
||||
@Column(name = "RANGE_FROM_DATE", nullable = true)
|
||||
private LocalDateTime rangeFromDate;
|
||||
|
||||
@Column(name = "RANGE_TO_DATE", nullable = true)
|
||||
private LocalDateTime rangeToDate;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "CREATED_AT", updatable = false, nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "UPDATED_AT", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public BatchLastExecution(String apiKey, LocalDateTime lastSuccessDate) {
|
||||
this.apiKey = apiKey;
|
||||
this.lastSuccessDate = lastSuccessDate;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
package com.snp.batch.global.partition;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 파티션 관리 설정 (application.yml 기반)
|
||||
*
|
||||
* 설정 예시:
|
||||
* app.batch.partition:
|
||||
* daily-tables:
|
||||
* - schema: snp_data
|
||||
* table-name: ais_target
|
||||
* partition-column: message_timestamp
|
||||
* periods-ahead: 3
|
||||
* monthly-tables:
|
||||
* - schema: snp_data
|
||||
* table-name: some_table
|
||||
* partition-column: created_at
|
||||
* periods-ahead: 2
|
||||
* retention:
|
||||
* daily-default-days: 14
|
||||
* monthly-default-months: 1
|
||||
* custom:
|
||||
* - table-name: ais_target
|
||||
* retention-days: 30
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.batch.partition")
|
||||
public class PartitionConfig {
|
||||
|
||||
/**
|
||||
* 일별 파티션 테이블 목록 (파티션 네이밍: {table}_YYMMDD)
|
||||
*/
|
||||
private List<PartitionTableConfig> dailyTables = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM)
|
||||
*/
|
||||
private List<PartitionTableConfig> monthlyTables = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 보관기간 설정
|
||||
*/
|
||||
private RetentionConfig retention = new RetentionConfig();
|
||||
|
||||
/**
|
||||
* 파티션 테이블 설정
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class PartitionTableConfig {
|
||||
private String schema = "snp_data";
|
||||
private String tableName;
|
||||
private String partitionColumn;
|
||||
private int periodsAhead = 3; // 미리 생성할 기간 수 (daily: 일, monthly: 월)
|
||||
|
||||
public String getFullTableName() {
|
||||
return schema + "." + tableName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보관기간 설정
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class RetentionConfig {
|
||||
/**
|
||||
* 일별 파티션 기본 보관기간 (일)
|
||||
*/
|
||||
private int dailyDefaultDays = 14;
|
||||
|
||||
/**
|
||||
* 월별 파티션 기본 보관기간 (개월)
|
||||
*/
|
||||
private int monthlyDefaultMonths = 1;
|
||||
|
||||
/**
|
||||
* 개별 테이블 보관기간 설정
|
||||
*/
|
||||
private List<CustomRetention> custom = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 테이블 보관기간 설정
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CustomRetention {
|
||||
private String tableName;
|
||||
private Integer retentionDays; // 일 단위 보관기간 (일별 파티션용)
|
||||
private Integer retentionMonths; // 월 단위 보관기간 (월별 파티션용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 파티션 테이블의 보관기간 조회 (일 단위)
|
||||
*/
|
||||
public int getDailyRetentionDays(String tableName) {
|
||||
return getCustomRetention(tableName)
|
||||
.map(c -> c.getRetentionDays() != null ? c.getRetentionDays() : retention.getDailyDefaultDays())
|
||||
.orElse(retention.getDailyDefaultDays());
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 파티션 테이블의 보관기간 조회 (월 단위)
|
||||
*/
|
||||
public int getMonthlyRetentionMonths(String tableName) {
|
||||
return getCustomRetention(tableName)
|
||||
.map(c -> c.getRetentionMonths() != null ? c.getRetentionMonths() : retention.getMonthlyDefaultMonths())
|
||||
.orElse(retention.getMonthlyDefaultMonths());
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 테이블 보관기간 설정 조회
|
||||
*/
|
||||
private Optional<CustomRetention> getCustomRetention(String tableName) {
|
||||
if (retention.getCustom() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return retention.getCustom().stream()
|
||||
.filter(c -> tableName.equals(c.getTableName()))
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.snp.batch.global.partition;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
import org.springframework.batch.core.JobExecutionListener;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* 파티션 관리 Job Config
|
||||
*
|
||||
* 스케줄: 매일 00:10 (0 10 0 * * ?)
|
||||
*
|
||||
* 동작:
|
||||
* - Daily 파티션: 매일 실행
|
||||
* - Monthly 파티션: 매월 말일에만 실행 (Job 내부에서 말일 감지)
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class PartitionManagerJobConfig {
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final PartitionManagerTasklet partitionManagerTasklet;
|
||||
|
||||
public PartitionManagerJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
PartitionManagerTasklet partitionManagerTasklet) {
|
||||
this.jobRepository = jobRepository;
|
||||
this.transactionManager = transactionManager;
|
||||
this.partitionManagerTasklet = partitionManagerTasklet;
|
||||
}
|
||||
|
||||
@Bean(name = "partitionManagerStep")
|
||||
public Step partitionManagerStep() {
|
||||
return new StepBuilder("partitionManagerStep", jobRepository)
|
||||
.tasklet(partitionManagerTasklet, transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean(name = "partitionManagerJob")
|
||||
public Job partitionManagerJob() {
|
||||
log.info("Job 생성: partitionManagerJob");
|
||||
|
||||
return new JobBuilder("partitionManagerJob", jobRepository)
|
||||
.listener(new JobExecutionListener() {
|
||||
@Override
|
||||
public void beforeJob(JobExecution jobExecution) {
|
||||
log.info("[partitionManagerJob] 파티션 관리 Job 시작");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterJob(JobExecution jobExecution) {
|
||||
log.info("[partitionManagerJob] 파티션 관리 Job 완료 - 상태: {}",
|
||||
jobExecution.getStatus());
|
||||
}
|
||||
})
|
||||
.start(partitionManagerStep())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,416 @@
|
||||
package com.snp.batch.global.partition;
|
||||
|
||||
import com.snp.batch.global.partition.PartitionConfig.PartitionTableConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 파티션 관리 Tasklet
|
||||
*
|
||||
* 스케줄: 매일 실행
|
||||
* - Daily 파티션: 매일 생성/삭제 (네이밍: {table}_YYMMDD)
|
||||
* - Monthly 파티션: 매월 말일에만 생성/삭제 (네이밍: {table}_YYYY_MM)
|
||||
*
|
||||
* 보관기간:
|
||||
* - 기본값: 일별 14일, 월별 1개월
|
||||
* - 개별 테이블별 보관기간 설정 가능 (application.yml)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PartitionManagerTasklet implements Tasklet {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PartitionConfig partitionConfig;
|
||||
|
||||
private static final DateTimeFormatter DAILY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyMMdd");
|
||||
private static final DateTimeFormatter MONTHLY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyyy_MM");
|
||||
|
||||
private static final String PARTITION_EXISTS_SQL = """
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = ?
|
||||
AND c.relname = ?
|
||||
AND c.relkind = 'r'
|
||||
)
|
||||
""";
|
||||
|
||||
private static final String FIND_PARTITIONS_SQL = """
|
||||
SELECT c.relname
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_inherits i ON i.inhrelid = c.oid
|
||||
WHERE n.nspname = ?
|
||||
AND c.relname LIKE ?
|
||||
AND c.relkind = 'r'
|
||||
ORDER BY c.relname
|
||||
""";
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
LocalDate today = LocalDate.now();
|
||||
boolean isLastDayOfMonth = isLastDayOfMonth(today);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("파티션 관리 Job 시작");
|
||||
log.info("실행 일자: {}", today);
|
||||
log.info("월 말일 여부: {}", isLastDayOfMonth);
|
||||
log.info("========================================");
|
||||
|
||||
// 1. Daily 파티션 생성 (매일)
|
||||
createDailyPartitions(today);
|
||||
|
||||
// 2. Daily 파티션 삭제 (보관기간 초과분)
|
||||
deleteDailyPartitions(today);
|
||||
|
||||
// 3. Monthly 파티션 생성 (매월 말일만)
|
||||
if (isLastDayOfMonth) {
|
||||
createMonthlyPartitions(today);
|
||||
} else {
|
||||
log.info("Monthly 파티션 생성: 말일이 아니므로 스킵");
|
||||
}
|
||||
|
||||
// 4. Monthly 파티션 삭제 (매월 1일에만, 보관기간 초과분)
|
||||
if (today.getDayOfMonth() == 1) {
|
||||
deleteMonthlyPartitions(today);
|
||||
} else {
|
||||
log.info("Monthly 파티션 삭제: 1일이 아니므로 스킵");
|
||||
}
|
||||
|
||||
log.info("========================================");
|
||||
log.info("파티션 관리 Job 완료");
|
||||
log.info("========================================");
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매월 말일 여부 확인
|
||||
*/
|
||||
private boolean isLastDayOfMonth(LocalDate date) {
|
||||
return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth();
|
||||
}
|
||||
|
||||
// ==================== Daily 파티션 생성 ====================
|
||||
|
||||
/**
|
||||
* Daily 파티션 생성
|
||||
*/
|
||||
private void createDailyPartitions(LocalDate today) {
|
||||
List<PartitionTableConfig> tables = partitionConfig.getDailyTables();
|
||||
|
||||
if (tables == null || tables.isEmpty()) {
|
||||
log.info("Daily 파티션 생성: 대상 테이블 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Daily 파티션 생성 시작: {} 개 테이블", tables.size());
|
||||
|
||||
for (PartitionTableConfig table : tables) {
|
||||
createDailyPartitionsForTable(table, today);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 테이블 Daily 파티션 생성
|
||||
*/
|
||||
private void createDailyPartitionsForTable(PartitionTableConfig table, LocalDate today) {
|
||||
List<String> created = new ArrayList<>();
|
||||
List<String> skipped = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i <= table.getPeriodsAhead(); i++) {
|
||||
LocalDate targetDate = today.plusDays(i);
|
||||
String partitionName = getDailyPartitionName(table.getTableName(), targetDate);
|
||||
|
||||
if (partitionExists(table.getSchema(), partitionName)) {
|
||||
skipped.add(partitionName);
|
||||
} else {
|
||||
createDailyPartition(table, targetDate, partitionName);
|
||||
created.add(partitionName);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[{}] Daily 파티션 생성 - 생성: {}, 스킵: {}",
|
||||
table.getTableName(), created.size(), skipped.size());
|
||||
if (!created.isEmpty()) {
|
||||
log.info("[{}] 생성된 파티션: {}", table.getTableName(), created);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Daily 파티션 삭제 ====================
|
||||
|
||||
/**
|
||||
* Daily 파티션 삭제 (보관기간 초과분)
|
||||
*/
|
||||
private void deleteDailyPartitions(LocalDate today) {
|
||||
List<PartitionTableConfig> tables = partitionConfig.getDailyTables();
|
||||
|
||||
if (tables == null || tables.isEmpty()) {
|
||||
log.info("Daily 파티션 삭제: 대상 테이블 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Daily 파티션 삭제 시작: {} 개 테이블", tables.size());
|
||||
|
||||
for (PartitionTableConfig table : tables) {
|
||||
int retentionDays = partitionConfig.getDailyRetentionDays(table.getTableName());
|
||||
deleteDailyPartitionsForTable(table, today, retentionDays);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 테이블 Daily 파티션 삭제
|
||||
*/
|
||||
private void deleteDailyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionDays) {
|
||||
LocalDate cutoffDate = today.minusDays(retentionDays);
|
||||
String likePattern = table.getTableName() + "_%";
|
||||
|
||||
List<String> partitions = jdbcTemplate.queryForList(
|
||||
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
|
||||
|
||||
List<String> deleted = new ArrayList<>();
|
||||
|
||||
for (String partitionName : partitions) {
|
||||
// 파티션 이름에서 날짜 추출 (table_YYMMDD)
|
||||
LocalDate partitionDate = parseDailyPartitionDate(table.getTableName(), partitionName);
|
||||
if (partitionDate != null && partitionDate.isBefore(cutoffDate)) {
|
||||
dropPartition(table.getSchema(), partitionName);
|
||||
deleted.add(partitionName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted.isEmpty()) {
|
||||
log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제: {} 개",
|
||||
table.getTableName(), retentionDays, deleted.size());
|
||||
log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted);
|
||||
} else {
|
||||
log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제할 파티션 없음",
|
||||
table.getTableName(), retentionDays);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Monthly 파티션 생성 ====================
|
||||
|
||||
/**
|
||||
* Monthly 파티션 생성
|
||||
*/
|
||||
private void createMonthlyPartitions(LocalDate today) {
|
||||
List<PartitionTableConfig> tables = partitionConfig.getMonthlyTables();
|
||||
|
||||
if (tables == null || tables.isEmpty()) {
|
||||
log.info("Monthly 파티션 생성: 대상 테이블 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Monthly 파티션 생성 시작: {} 개 테이블", tables.size());
|
||||
|
||||
for (PartitionTableConfig table : tables) {
|
||||
createMonthlyPartitionsForTable(table, today);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 테이블 Monthly 파티션 생성
|
||||
*/
|
||||
private void createMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today) {
|
||||
List<String> created = new ArrayList<>();
|
||||
List<String> skipped = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i <= table.getPeriodsAhead(); i++) {
|
||||
LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1);
|
||||
String partitionName = getMonthlyPartitionName(table.getTableName(), targetDate);
|
||||
|
||||
if (partitionExists(table.getSchema(), partitionName)) {
|
||||
skipped.add(partitionName);
|
||||
} else {
|
||||
createMonthlyPartition(table, targetDate, partitionName);
|
||||
created.add(partitionName);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[{}] Monthly 파티션 생성 - 생성: {}, 스킵: {}",
|
||||
table.getTableName(), created.size(), skipped.size());
|
||||
if (!created.isEmpty()) {
|
||||
log.info("[{}] 생성된 파티션: {}", table.getTableName(), created);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Monthly 파티션 삭제 ====================
|
||||
|
||||
/**
|
||||
* Monthly 파티션 삭제 (보관기간 초과분)
|
||||
*/
|
||||
private void deleteMonthlyPartitions(LocalDate today) {
|
||||
List<PartitionTableConfig> tables = partitionConfig.getMonthlyTables();
|
||||
|
||||
if (tables == null || tables.isEmpty()) {
|
||||
log.info("Monthly 파티션 삭제: 대상 테이블 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Monthly 파티션 삭제 시작: {} 개 테이블", tables.size());
|
||||
|
||||
for (PartitionTableConfig table : tables) {
|
||||
int retentionMonths = partitionConfig.getMonthlyRetentionMonths(table.getTableName());
|
||||
deleteMonthlyPartitionsForTable(table, today, retentionMonths);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 테이블 Monthly 파티션 삭제
|
||||
*/
|
||||
private void deleteMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionMonths) {
|
||||
LocalDate cutoffDate = today.minusMonths(retentionMonths).withDayOfMonth(1);
|
||||
String likePattern = table.getTableName() + "_%";
|
||||
|
||||
List<String> partitions = jdbcTemplate.queryForList(
|
||||
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
|
||||
|
||||
List<String> deleted = new ArrayList<>();
|
||||
|
||||
for (String partitionName : partitions) {
|
||||
// 파티션 이름에서 날짜 추출 (table_YYYY_MM)
|
||||
LocalDate partitionDate = parseMonthlyPartitionDate(table.getTableName(), partitionName);
|
||||
if (partitionDate != null && partitionDate.isBefore(cutoffDate)) {
|
||||
dropPartition(table.getSchema(), partitionName);
|
||||
deleted.add(partitionName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted.isEmpty()) {
|
||||
log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제: {} 개",
|
||||
table.getTableName(), retentionMonths, deleted.size());
|
||||
log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted);
|
||||
} else {
|
||||
log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제할 파티션 없음",
|
||||
table.getTableName(), retentionMonths);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 파티션 이름 생성 ====================
|
||||
|
||||
/**
|
||||
* Daily 파티션 이름 생성 (table_YYMMDD)
|
||||
*/
|
||||
private String getDailyPartitionName(String tableName, LocalDate date) {
|
||||
return tableName + "_" + date.format(DAILY_PARTITION_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly 파티션 이름 생성 (table_YYYY_MM)
|
||||
*/
|
||||
private String getMonthlyPartitionName(String tableName, LocalDate date) {
|
||||
return tableName + "_" + date.format(MONTHLY_PARTITION_FORMAT);
|
||||
}
|
||||
|
||||
// ==================== 파티션 이름에서 날짜 추출 ====================
|
||||
|
||||
/**
|
||||
* Daily 파티션 이름에서 날짜 추출 (table_YYMMDD -> LocalDate)
|
||||
*/
|
||||
private LocalDate parseDailyPartitionDate(String tableName, String partitionName) {
|
||||
try {
|
||||
String prefix = tableName + "_";
|
||||
if (!partitionName.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
String dateStr = partitionName.substring(prefix.length());
|
||||
// YYMMDD 형식 (6자리)
|
||||
if (dateStr.length() == 6 && dateStr.matches("\\d{6}")) {
|
||||
return LocalDate.parse(dateStr, DAILY_PARTITION_FORMAT);
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.trace("파티션 날짜 파싱 실패: {}", partitionName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly 파티션 이름에서 날짜 추출 (table_YYYY_MM -> LocalDate)
|
||||
*/
|
||||
private LocalDate parseMonthlyPartitionDate(String tableName, String partitionName) {
|
||||
try {
|
||||
String prefix = tableName + "_";
|
||||
if (!partitionName.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
String dateStr = partitionName.substring(prefix.length());
|
||||
// YYYY_MM 형식 (7자리)
|
||||
if (dateStr.length() == 7 && dateStr.matches("\\d{4}_\\d{2}")) {
|
||||
return LocalDate.parse(dateStr + "_01", DateTimeFormatter.ofPattern("yyyy_MM_dd"));
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.trace("파티션 날짜 파싱 실패: {}", partitionName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DB 작업 ====================
|
||||
|
||||
/**
|
||||
* 파티션 존재 여부 확인
|
||||
*/
|
||||
private boolean partitionExists(String schema, String partitionName) {
|
||||
Boolean exists = jdbcTemplate.queryForObject(PARTITION_EXISTS_SQL, Boolean.class, schema, partitionName);
|
||||
return Boolean.TRUE.equals(exists);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily 파티션 생성
|
||||
*/
|
||||
private void createDailyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) {
|
||||
LocalDate endDate = targetDate.plusDays(1);
|
||||
|
||||
String sql = String.format("""
|
||||
CREATE TABLE %s.%s PARTITION OF %s
|
||||
FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
|
||||
""",
|
||||
table.getSchema(), partitionName, table.getFullTableName(),
|
||||
targetDate, endDate);
|
||||
|
||||
jdbcTemplate.execute(sql);
|
||||
log.debug("Daily 파티션 생성: {}", partitionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly 파티션 생성
|
||||
*/
|
||||
private void createMonthlyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) {
|
||||
LocalDate startDate = targetDate.withDayOfMonth(1);
|
||||
LocalDate endDate = startDate.plusMonths(1);
|
||||
|
||||
String sql = String.format("""
|
||||
CREATE TABLE %s.%s PARTITION OF %s
|
||||
FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
|
||||
""",
|
||||
table.getSchema(), partitionName, table.getFullTableName(),
|
||||
startDate, endDate);
|
||||
|
||||
jdbcTemplate.execute(sql);
|
||||
log.debug("Monthly 파티션 생성: {}", partitionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파티션 삭제
|
||||
*/
|
||||
private void dropPartition(String schema, String partitionName) {
|
||||
String sql = String.format("DROP TABLE IF EXISTS %s.%s", schema, partitionName);
|
||||
jdbcTemplate.execute(sql);
|
||||
log.debug("파티션 삭제: {}", partitionName);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.snp.batch.global.projection;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public interface DateRangeProjection {
|
||||
LocalDateTime getLastSuccessDate();
|
||||
LocalDateTime getRangeFromDate();
|
||||
LocalDateTime getRangeToDate();
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.snp.batch.global.repository;
|
||||
|
||||
import com.snp.batch.global.model.BatchApiLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long> {
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.snp.batch.global.repository;
|
||||
|
||||
import com.snp.batch.global.model.BatchLastExecution;
|
||||
import com.snp.batch.global.projection.DateRangeProjection;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface BatchLastExecutionRepository extends JpaRepository<BatchLastExecution, String> {
|
||||
// 1. findLastSuccessDate 함수 구현
|
||||
/**
|
||||
* API 키를 기준으로 마지막 성공 일자를 조회합니다.
|
||||
* @param apiKey 조회할 API 키 (예: "SHIP_UPDATE_API")
|
||||
* @return 마지막 성공 일자 (LocalDate)를 포함하는 Optional
|
||||
*/
|
||||
@Query("SELECT b.lastSuccessDate FROM BatchLastExecution b WHERE b.apiKey = :apiKey")
|
||||
Optional<LocalDate> findLastSuccessDate(@Param("apiKey") String apiKey);
|
||||
|
||||
// 2. findDateRangeByApiKey 함수 구현
|
||||
/**
|
||||
* API 키를 기준으로 범위 설정 날짜를 조회합니다.
|
||||
* @param apiKey 조회할 API 키 (예: "PSC_IMPORT_API")
|
||||
* @return 마지막 성공 일자 (LocalDate)를 포함하는 Optional
|
||||
*/
|
||||
@Query("SELECT b.lastSuccessDate AS lastSuccessDate, b.rangeFromDate AS rangeFromDate, b.rangeToDate AS rangeToDate FROM BatchLastExecution b WHERE b.apiKey = :apiKey")
|
||||
Optional<DateRangeProjection> findDateRangeByApiKey(@Param("apiKey") String apiKey);
|
||||
|
||||
// 3. updateLastSuccessDate 함수 구현 (직접 UPDATE 쿼리 사용)
|
||||
/**
|
||||
* 특정 API 키의 마지막 성공 일자를 업데이트합니다.
|
||||
*
|
||||
* @param apiKey 업데이트할 API 키
|
||||
* @param successDate 업데이트할 성공 일자
|
||||
* @return 업데이트된 레코드 수
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BatchLastExecution b SET b.lastSuccessDate = :successDate WHERE b.apiKey = :apiKey")
|
||||
int updateLastSuccessDate(@Param("apiKey") String apiKey, @Param("successDate") LocalDateTime successDate);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
package com.snp.batch.global.repository;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@ -13,10 +13,25 @@ import java.util.Map;
|
||||
* Step Context 등 불필요한 데이터를 조회하지 않고 필요한 정보만 가져옴
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class TimelineRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final String tablePrefix;
|
||||
|
||||
public TimelineRepository(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
@Value("${spring.batch.jdbc.table-prefix:BATCH_}") String tablePrefix) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.tablePrefix = tablePrefix;
|
||||
}
|
||||
|
||||
private String getJobExecutionTable() {
|
||||
return tablePrefix + "JOB_EXECUTION";
|
||||
}
|
||||
|
||||
private String getJobInstanceTable() {
|
||||
return tablePrefix + "JOB_INSTANCE";
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
||||
@ -27,19 +42,19 @@ public class TimelineRepository {
|
||||
LocalDateTime startTime,
|
||||
LocalDateTime endTime) {
|
||||
|
||||
String sql = """
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime,
|
||||
je.END_TIME as endTime
|
||||
FROM BATCH_JOB_EXECUTION je
|
||||
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE ji.JOB_NAME = ?
|
||||
AND je.START_TIME >= ?
|
||||
AND je.START_TIME < ?
|
||||
ORDER BY je.START_TIME DESC
|
||||
""";
|
||||
""", getJobExecutionTable(), getJobInstanceTable());
|
||||
|
||||
return jdbcTemplate.queryForList(sql, jobName, startTime, endTime);
|
||||
}
|
||||
@ -51,19 +66,19 @@ public class TimelineRepository {
|
||||
LocalDateTime startTime,
|
||||
LocalDateTime endTime) {
|
||||
|
||||
String sql = """
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
ji.JOB_NAME as jobName,
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime,
|
||||
je.END_TIME as endTime
|
||||
FROM BATCH_JOB_EXECUTION je
|
||||
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE je.START_TIME >= ?
|
||||
AND je.START_TIME < ?
|
||||
ORDER BY ji.JOB_NAME, je.START_TIME DESC
|
||||
""";
|
||||
""", getJobExecutionTable(), getJobInstanceTable());
|
||||
|
||||
return jdbcTemplate.queryForList(sql, startTime, endTime);
|
||||
}
|
||||
@ -72,17 +87,17 @@ public class TimelineRepository {
|
||||
* 현재 실행 중인 Job 조회 (STARTED, STARTING 상태)
|
||||
*/
|
||||
public List<Map<String, Object>> findRunningExecutions() {
|
||||
String sql = """
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
ji.JOB_NAME as jobName,
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime
|
||||
FROM BATCH_JOB_EXECUTION je
|
||||
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||
ORDER BY je.START_TIME DESC
|
||||
""";
|
||||
""", getJobExecutionTable(), getJobInstanceTable());
|
||||
|
||||
return jdbcTemplate.queryForList(sql);
|
||||
}
|
||||
@ -91,18 +106,18 @@ public class TimelineRepository {
|
||||
* 최근 실행 이력 조회 (상위 N개)
|
||||
*/
|
||||
public List<Map<String, Object>> findRecentExecutions(int limit) {
|
||||
String sql = """
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
ji.JOB_NAME as jobName,
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime,
|
||||
je.END_TIME as endTime
|
||||
FROM BATCH_JOB_EXECUTION je
|
||||
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
ORDER BY je.START_TIME DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
""", getJobExecutionTable(), getJobInstanceTable());
|
||||
|
||||
return jdbcTemplate.queryForList(sql, limit);
|
||||
}
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.processor.AisTargetDataProcessor;
|
||||
import com.snp.batch.jobs.aistarget.batch.reader.AisTargetDataReader;
|
||||
import com.snp.batch.jobs.aistarget.batch.writer.AisTargetDataWriter;
|
||||
import com.snp.batch.jobs.aistarget.classifier.Core20CacheManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
import org.springframework.batch.core.JobExecutionListener;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* AIS Target Import Job Config
|
||||
*
|
||||
* 스케줄: 매 분 15초 (0 15 * * * * ?)
|
||||
* API: POST /AisSvc.svc/AIS/GetTargets
|
||||
* 파라미터: {"sinceSeconds": "60"}
|
||||
*
|
||||
* 동작:
|
||||
* - 최근 60초 동안의 전체 선박 위치 정보 수집
|
||||
* - 약 33,000건/분 처리
|
||||
* - UPSERT 방식으로 DB 저장
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTargetEntity> {
|
||||
|
||||
private final AisTargetDataProcessor aisTargetDataProcessor;
|
||||
private final AisTargetDataWriter aisTargetDataWriter;
|
||||
private final WebClient maritimeAisApiWebClient;
|
||||
private final Core20CacheManager core20CacheManager;
|
||||
|
||||
@Value("${app.batch.ais-target.since-seconds:60}")
|
||||
private int sinceSeconds;
|
||||
|
||||
@Value("${app.batch.ais-target.chunk-size:5000}")
|
||||
private int chunkSize;
|
||||
|
||||
public AisTargetImportJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
AisTargetDataProcessor aisTargetDataProcessor,
|
||||
AisTargetDataWriter aisTargetDataWriter,
|
||||
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient,
|
||||
Core20CacheManager core20CacheManager) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.aisTargetDataProcessor = aisTargetDataProcessor;
|
||||
this.aisTargetDataWriter = aisTargetDataWriter;
|
||||
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
|
||||
this.core20CacheManager = core20CacheManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() {
|
||||
return "aisTargetImportJob";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "aisTargetImportStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<AisTargetDto> createReader() {
|
||||
return new AisTargetDataReader(maritimeAisApiWebClient, sinceSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<AisTargetDto, AisTargetEntity> createProcessor() {
|
||||
return aisTargetDataProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<AisTargetEntity> createWriter() {
|
||||
return aisTargetDataWriter;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getChunkSize() {
|
||||
return chunkSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void configureJob(JobBuilder jobBuilder) {
|
||||
jobBuilder.listener(new JobExecutionListener() {
|
||||
@Override
|
||||
public void beforeJob(JobExecution jobExecution) {
|
||||
// 배치 수집 시점 설정
|
||||
OffsetDateTime collectedAt = OffsetDateTime.now();
|
||||
aisTargetDataProcessor.setCollectedAt(collectedAt);
|
||||
log.info("[{}] Job 시작 - 수집 시점: {}", getJobName(), collectedAt);
|
||||
|
||||
// Core20 캐시 관리
|
||||
// 1. 캐시가 비어있으면 즉시 로딩 (첫 실행 또는 재시작 시)
|
||||
// 2. 지정된 시간대(기본 04:00)이면 일일 갱신 수행
|
||||
if (!core20CacheManager.isLoaded()) {
|
||||
log.info("[{}] Core20 캐시 초기 로딩 시작", getJobName());
|
||||
core20CacheManager.refresh();
|
||||
} else if (core20CacheManager.shouldRefresh()) {
|
||||
log.info("[{}] Core20 캐시 일일 갱신 시작 (스케줄: {}시)",
|
||||
getJobName(), core20CacheManager.getLastRefreshTime());
|
||||
core20CacheManager.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterJob(JobExecution jobExecution) {
|
||||
log.info("[{}] Job 완료 - 상태: {}, 처리 건수: {}, Core20 캐시 크기: {}",
|
||||
getJobName(),
|
||||
jobExecution.getStatus(),
|
||||
jobExecution.getStepExecutions().stream()
|
||||
.mapToLong(se -> se.getWriteCount())
|
||||
.sum(),
|
||||
core20CacheManager.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Bean(name = "aisTargetImportJob")
|
||||
public Job aisTargetImportJob() {
|
||||
return job();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AIS GetTargets API 응답 래퍼
|
||||
*
|
||||
* API 응답 구조:
|
||||
* {
|
||||
* "targetArr": [...]
|
||||
* }
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AisTargetApiResponse {
|
||||
|
||||
@JsonProperty("targetEnhancedArr")
|
||||
private List<AisTargetDto> targetArr;
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* AIS Target API 응답 DTO
|
||||
*
|
||||
* API: POST /AisSvc.svc/AIS/GetTargets
|
||||
* Request: {"sinceSeconds": "60"}
|
||||
* Response: {"targetArr": [...]}
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AisTargetDto {
|
||||
|
||||
@JsonProperty("MMSI")
|
||||
private Long mmsi;
|
||||
|
||||
@JsonProperty("IMO")
|
||||
private Long imo;
|
||||
|
||||
@JsonProperty("AgeMinutes")
|
||||
private Double ageMinutes;
|
||||
|
||||
@JsonProperty("Lat")
|
||||
private Double lat;
|
||||
|
||||
@JsonProperty("Lon")
|
||||
private Double lon;
|
||||
|
||||
@JsonProperty("Heading")
|
||||
private Double heading;
|
||||
|
||||
@JsonProperty("SoG")
|
||||
private Double sog; // Speed over Ground
|
||||
|
||||
@JsonProperty("CoG")
|
||||
private Double cog; // Course over Ground (if available)
|
||||
|
||||
@JsonProperty("Width")
|
||||
private Integer width;
|
||||
|
||||
@JsonProperty("Length")
|
||||
private Integer length;
|
||||
|
||||
@JsonProperty("Draught")
|
||||
private Double draught;
|
||||
|
||||
@JsonProperty("Name")
|
||||
private String name;
|
||||
|
||||
@JsonProperty("Callsign")
|
||||
private String callsign;
|
||||
|
||||
@JsonProperty("Destination")
|
||||
private String destination;
|
||||
|
||||
@JsonProperty("ETA")
|
||||
private String eta;
|
||||
|
||||
@JsonProperty("Status")
|
||||
private String status;
|
||||
|
||||
@JsonProperty("VesselType")
|
||||
private String vesselType;
|
||||
|
||||
@JsonProperty("ExtraInfo")
|
||||
private String extraInfo;
|
||||
|
||||
@JsonProperty("PositionAccuracy")
|
||||
private Integer positionAccuracy;
|
||||
|
||||
@JsonProperty("RoT")
|
||||
private Integer rot; // Rate of Turn
|
||||
|
||||
@JsonProperty("TimestampUTC")
|
||||
private Integer timestampUtc;
|
||||
|
||||
@JsonProperty("RepeatIndicator")
|
||||
private Integer repeatIndicator;
|
||||
|
||||
@JsonProperty("RAIMFlag")
|
||||
private Integer raimFlag;
|
||||
|
||||
@JsonProperty("RadioStatus")
|
||||
private Integer radioStatus;
|
||||
|
||||
@JsonProperty("Regional")
|
||||
private Integer regional;
|
||||
|
||||
@JsonProperty("Regional2")
|
||||
private Integer regional2;
|
||||
|
||||
@JsonProperty("Spare")
|
||||
private Integer spare;
|
||||
|
||||
@JsonProperty("Spare2")
|
||||
private Integer spare2;
|
||||
|
||||
@JsonProperty("AISVersion")
|
||||
private Integer aisVersion;
|
||||
|
||||
@JsonProperty("PositionFixType")
|
||||
private Integer positionFixType;
|
||||
|
||||
@JsonProperty("DTE")
|
||||
private Integer dte;
|
||||
|
||||
@JsonProperty("BandFlag")
|
||||
private Integer bandFlag;
|
||||
|
||||
@JsonProperty("ReceivedDate")
|
||||
private String receivedDate;
|
||||
|
||||
@JsonProperty("MessageTimestamp")
|
||||
private String messageTimestamp;
|
||||
|
||||
@JsonProperty("LengthBow")
|
||||
private Integer lengthBow;
|
||||
|
||||
@JsonProperty("LengthStern")
|
||||
private Integer lengthStern;
|
||||
|
||||
@JsonProperty("WidthPort")
|
||||
private Integer widthPort;
|
||||
|
||||
@JsonProperty("WidthStarboard")
|
||||
private Integer widthStarboard;
|
||||
|
||||
// TargetEnhanced 컬럼 추가
|
||||
@JsonProperty("TonnesCargo")
|
||||
private Integer tonnesCargo;
|
||||
@JsonProperty("InSTS")
|
||||
private Integer inSTS;
|
||||
@JsonProperty("OnBerth")
|
||||
private Boolean onBerth;
|
||||
@JsonProperty("DWT")
|
||||
private Integer dwt;
|
||||
@JsonProperty("Anomalous")
|
||||
private String anomalous;
|
||||
@JsonProperty("DestinationPortID")
|
||||
private Integer destinationPortID;
|
||||
@JsonProperty("DestinationTidied")
|
||||
private String destinationTidied;
|
||||
@JsonProperty("DestinationUNLOCODE")
|
||||
private String destinationUNLOCODE;
|
||||
@JsonProperty("ImoVerified")
|
||||
private String imoVerified;
|
||||
@JsonProperty("LastStaticUpdateReceived")
|
||||
private String lastStaticUpdateReceived;
|
||||
@JsonProperty("LPCCode")
|
||||
private Integer lpcCode;
|
||||
@JsonProperty("MessageType")
|
||||
private Integer messageType;
|
||||
@JsonProperty("Source")
|
||||
private String source;
|
||||
@JsonProperty("StationId")
|
||||
private String stationId;
|
||||
@JsonProperty("ZoneId")
|
||||
private Double zoneId;
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* AIS Target Entity
|
||||
*
|
||||
* 테이블: snp_data.ais_target
|
||||
* PK: mmsi + message_timestamp (복합키)
|
||||
*
|
||||
* 용도:
|
||||
* - 선박 위치 이력 저장 (항적 분석용)
|
||||
* - 특정 시점/구역 선박 조회
|
||||
* - LineString 항적 생성 기반 데이터
|
||||
*/
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AisTargetEntity extends BaseEntity {
|
||||
|
||||
// ========== PK (복합키) ==========
|
||||
private Long mmsi;
|
||||
private OffsetDateTime messageTimestamp;
|
||||
|
||||
// ========== 선박 식별 정보 ==========
|
||||
private Long imo;
|
||||
private String name;
|
||||
private String callsign;
|
||||
private String vesselType;
|
||||
private String extraInfo;
|
||||
|
||||
// ========== 위치 정보 ==========
|
||||
private Double lat;
|
||||
private Double lon;
|
||||
// geom은 DB에서 ST_SetSRID(ST_MakePoint(lon, lat), 4326)로 생성
|
||||
|
||||
// ========== 항해 정보 ==========
|
||||
private Double heading;
|
||||
private Double sog; // Speed over Ground
|
||||
private Double cog; // Course over Ground
|
||||
private Integer rot; // Rate of Turn
|
||||
|
||||
// ========== 선박 제원 ==========
|
||||
private Integer length;
|
||||
private Integer width;
|
||||
private Double draught;
|
||||
private Integer lengthBow;
|
||||
private Integer lengthStern;
|
||||
private Integer widthPort;
|
||||
private Integer widthStarboard;
|
||||
|
||||
// ========== 목적지 정보 ==========
|
||||
private String destination;
|
||||
private OffsetDateTime eta;
|
||||
private String status;
|
||||
|
||||
// ========== AIS 메시지 정보 ==========
|
||||
private Double ageMinutes;
|
||||
private Integer positionAccuracy;
|
||||
private Integer timestampUtc;
|
||||
private Integer repeatIndicator;
|
||||
private Integer raimFlag;
|
||||
private Integer radioStatus;
|
||||
private Integer regional;
|
||||
private Integer regional2;
|
||||
private Integer spare;
|
||||
private Integer spare2;
|
||||
private Integer aisVersion;
|
||||
private Integer positionFixType;
|
||||
private Integer dte;
|
||||
private Integer bandFlag;
|
||||
|
||||
// ========== 타임스탬프 ==========
|
||||
private OffsetDateTime receivedDate;
|
||||
private OffsetDateTime collectedAt; // 배치 수집 시점
|
||||
|
||||
// ========== ClassType 분류 정보 ==========
|
||||
/**
|
||||
* 선박 클래스 타입
|
||||
* - "A": Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
* - "B": Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
* - null: 미분류 (캐시 저장 전)
|
||||
*/
|
||||
private String classType;
|
||||
|
||||
/**
|
||||
* Core20 테이블의 MMSI 값
|
||||
* - Class A인 경우에만 값이 있을 수 있음
|
||||
* - Class A이지만 Core20에 MMSI가 없는 경우 null
|
||||
* - Class B인 경우 항상 null
|
||||
*/
|
||||
private String core20Mmsi;
|
||||
|
||||
// TargetEnhanced 컬럼 추가
|
||||
private Integer tonnesCargo;
|
||||
private Integer inSTS;
|
||||
private Boolean onBerth;
|
||||
private Integer dwt;
|
||||
private String anomalous;
|
||||
private Integer destinationPortID;
|
||||
private String destinationTidied;
|
||||
private String destinationUNLOCODE;
|
||||
private String imoVerified;
|
||||
private OffsetDateTime lastStaticUpdateReceived;
|
||||
private Integer lpcCode;
|
||||
private Integer messageType;
|
||||
private String source;
|
||||
private String stationId;
|
||||
private Double zoneId;
|
||||
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.processor;
|
||||
|
||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* AIS Target 데이터 Processor
|
||||
*
|
||||
* DTO → Entity 변환
|
||||
* - 타임스탬프 파싱
|
||||
* - 필터링 (유효한 위치 정보만)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetDataProcessor extends BaseProcessor<AisTargetDto, AisTargetEntity> {
|
||||
|
||||
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
|
||||
|
||||
// 배치 수집 시점 (모든 레코드에 동일하게 적용)
|
||||
private OffsetDateTime collectedAt;
|
||||
|
||||
public void setCollectedAt(OffsetDateTime collectedAt) {
|
||||
this.collectedAt = collectedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AisTargetEntity processItem(AisTargetDto dto) throws Exception {
|
||||
// 유효성 검사: MMSI와 위치 정보는 필수
|
||||
if (dto.getMmsi() == null || dto.getLat() == null || dto.getLon() == null) {
|
||||
log.debug("유효하지 않은 데이터 스킵 - MMSI: {}, Lat: {}, Lon: {}",
|
||||
dto.getMmsi(), dto.getLat(), dto.getLon());
|
||||
return null;
|
||||
}
|
||||
|
||||
// MessageTimestamp 파싱 (PK의 일부)
|
||||
OffsetDateTime messageTimestamp = parseTimestamp(dto.getMessageTimestamp());
|
||||
if (messageTimestamp == null) {
|
||||
log.debug("MessageTimestamp 파싱 실패 - MMSI: {}, Timestamp: {}",
|
||||
dto.getMmsi(), dto.getMessageTimestamp());
|
||||
return null;
|
||||
}
|
||||
|
||||
return AisTargetEntity.builder()
|
||||
// PK
|
||||
.mmsi(dto.getMmsi())
|
||||
.messageTimestamp(messageTimestamp)
|
||||
// 선박 식별 정보
|
||||
.imo(dto.getImo())
|
||||
.name(dto.getName())
|
||||
.callsign(dto.getCallsign())
|
||||
.vesselType(dto.getVesselType())
|
||||
.extraInfo(dto.getExtraInfo())
|
||||
// 위치 정보
|
||||
.lat(dto.getLat())
|
||||
.lon(dto.getLon())
|
||||
// 항해 정보
|
||||
.heading(dto.getHeading())
|
||||
.sog(dto.getSog())
|
||||
.cog(dto.getCog())
|
||||
.rot(dto.getRot())
|
||||
// 선박 제원
|
||||
.length(dto.getLength())
|
||||
.width(dto.getWidth())
|
||||
.draught(dto.getDraught())
|
||||
.lengthBow(dto.getLengthBow())
|
||||
.lengthStern(dto.getLengthStern())
|
||||
.widthPort(dto.getWidthPort())
|
||||
.widthStarboard(dto.getWidthStarboard())
|
||||
// 목적지 정보
|
||||
.destination(dto.getDestination())
|
||||
.eta(parseEta(dto.getEta()))
|
||||
.status(dto.getStatus())
|
||||
// AIS 메시지 정보
|
||||
.ageMinutes(dto.getAgeMinutes())
|
||||
.positionAccuracy(dto.getPositionAccuracy())
|
||||
.timestampUtc(dto.getTimestampUtc())
|
||||
.repeatIndicator(dto.getRepeatIndicator())
|
||||
.raimFlag(dto.getRaimFlag())
|
||||
.radioStatus(dto.getRadioStatus())
|
||||
.regional(dto.getRegional())
|
||||
.regional2(dto.getRegional2())
|
||||
.spare(dto.getSpare())
|
||||
.spare2(dto.getSpare2())
|
||||
.aisVersion(dto.getAisVersion())
|
||||
.positionFixType(dto.getPositionFixType())
|
||||
.dte(dto.getDte())
|
||||
.bandFlag(dto.getBandFlag())
|
||||
// 타임스탬프
|
||||
.receivedDate(parseTimestamp(dto.getReceivedDate()))
|
||||
.collectedAt(collectedAt != null ? collectedAt : OffsetDateTime.now())
|
||||
// TargetEnhanced 컬럼 추가
|
||||
.tonnesCargo(dto.getTonnesCargo())
|
||||
.inSTS(dto.getInSTS())
|
||||
.onBerth(dto.getOnBerth())
|
||||
.dwt(dto.getDwt())
|
||||
.anomalous(dto.getAnomalous())
|
||||
.destinationPortID(dto.getDestinationPortID())
|
||||
.destinationTidied(dto.getDestinationTidied())
|
||||
.destinationUNLOCODE(dto.getDestinationUNLOCODE())
|
||||
.imoVerified(dto.getImoVerified())
|
||||
.lastStaticUpdateReceived(parseTimestamp(dto.getLastStaticUpdateReceived()))
|
||||
.lpcCode(dto.getLpcCode())
|
||||
.messageType(dto.getMessageType())
|
||||
.source(dto.getSource())
|
||||
.stationId(dto.getStationId())
|
||||
.zoneId(dto.getZoneId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private OffsetDateTime parseTimestamp(String timestamp) {
|
||||
if (timestamp == null || timestamp.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// ISO 8601 형식 파싱 (예: "2025-12-01T23:55:01.073Z")
|
||||
return OffsetDateTime.parse(timestamp, ISO_FORMATTER);
|
||||
} catch (DateTimeParseException e) {
|
||||
log.trace("타임스탬프 파싱 실패: {}", timestamp);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private OffsetDateTime parseEta(String eta) {
|
||||
if (eta == null || eta.isEmpty() || "9999-12-31T23:59:59Z".equals(eta)) {
|
||||
return null; // 유효하지 않은 ETA는 null 처리
|
||||
}
|
||||
return parseTimestamp(eta);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.reader;
|
||||
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetApiResponse;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AIS Target 데이터 Reader
|
||||
*
|
||||
* API: POST /AisSvc.svc/AIS/GetTargets
|
||||
* Request: {"sinceSeconds": "60"}
|
||||
*
|
||||
* 동작:
|
||||
* - 매 분 15초에 실행 (Quartz 스케줄)
|
||||
* - 최근 60초 동안의 전체 선박 위치 정보 조회
|
||||
* - 약 33,000건/분 처리
|
||||
*/
|
||||
@Slf4j
|
||||
public class AisTargetDataReader extends BaseApiReader<AisTargetDto> {
|
||||
|
||||
private final int sinceSeconds;
|
||||
|
||||
public AisTargetDataReader(WebClient webClient, int sinceSeconds) {
|
||||
super(webClient);
|
||||
this.sinceSeconds = sinceSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "AisTargetDataReader";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getApiPath() {
|
||||
return "/AisSvc.svc/AIS/GetTargetsEnhanced";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getHttpMethod() {
|
||||
return "POST";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object getRequestBody() {
|
||||
return Map.of("sinceSeconds", String.valueOf(sinceSeconds));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> getResponseType() {
|
||||
return AisTargetApiResponse.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeFetch() {
|
||||
log.info("[{}] AIS GetTargets API 호출 준비 - sinceSeconds: {}", getReaderName(), sinceSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<AisTargetDto> fetchDataFromApi() {
|
||||
try {
|
||||
log.info("[{}] API 호출 시작: {} {}", getReaderName(), getHttpMethod(), getApiPath());
|
||||
|
||||
AisTargetApiResponse response = webClient.post()
|
||||
.uri(getApiPath())
|
||||
.bodyValue(getRequestBody())
|
||||
.retrieve()
|
||||
.bodyToMono(AisTargetApiResponse.class)
|
||||
.block();
|
||||
|
||||
if (response != null && response.getTargetArr() != null) {
|
||||
List<AisTargetDto> targets = response.getTargetArr();
|
||||
log.info("[{}] API 호출 완료: {} 건 조회", getReaderName(), targets.size());
|
||||
updateApiCallStats(1, 1);
|
||||
return targets;
|
||||
} else {
|
||||
log.warn("[{}] API 응답이 비어있습니다", getReaderName());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e);
|
||||
return handleApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterFetch(List<AisTargetDto> data) {
|
||||
if (data != null && !data.isEmpty()) {
|
||||
log.info("[{}] 데이터 조회 완료 - 총 {} 건", getReaderName(), data.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AIS Target Repository 인터페이스
|
||||
*/
|
||||
public interface AisTargetRepository {
|
||||
|
||||
/**
|
||||
* 복합키로 조회 (MMSI + MessageTimestamp)
|
||||
*/
|
||||
Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp);
|
||||
|
||||
/**
|
||||
* MMSI로 최신 위치 조회
|
||||
*/
|
||||
Optional<AisTargetEntity> findLatestByMmsi(Long mmsi);
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회
|
||||
*/
|
||||
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
|
||||
|
||||
/**
|
||||
* 시간 범위 내 특정 MMSI의 항적 조회
|
||||
*/
|
||||
List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end);
|
||||
|
||||
/**
|
||||
* 시간 범위 + 공간 범위 내 선박 조회
|
||||
*/
|
||||
List<AisTargetEntity> findByTimeRangeAndArea(
|
||||
OffsetDateTime start,
|
||||
OffsetDateTime end,
|
||||
Double centerLon,
|
||||
Double centerLat,
|
||||
Double radiusMeters
|
||||
);
|
||||
|
||||
/**
|
||||
* 배치 INSERT (UPSERT)
|
||||
*/
|
||||
void batchUpsert(List<AisTargetEntity> entities);
|
||||
|
||||
/**
|
||||
* 전체 건수 조회
|
||||
*/
|
||||
long count();
|
||||
|
||||
/**
|
||||
* 오래된 데이터 삭제 (보존 기간 이전 데이터)
|
||||
*/
|
||||
int deleteOlderThan(OffsetDateTime threshold);
|
||||
}
|
||||
@ -0,0 +1,367 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AIS Target Repository 구현체
|
||||
*
|
||||
* 테이블: snp_data.ais_target
|
||||
* PK: mmsi + message_timestamp (복합키)
|
||||
*/
|
||||
@Slf4j
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final String TABLE_NAME = "snp_data.ais_target";
|
||||
|
||||
// ==================== UPSERT SQL ====================
|
||||
|
||||
private static final String UPSERT_SQL = """
|
||||
INSERT INTO snp_data.ais_target (
|
||||
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
|
||||
lat, lon, geom,
|
||||
heading, sog, cog, rot,
|
||||
length, width, draught, length_bow, length_stern, width_port, width_starboard,
|
||||
destination, eta, status,
|
||||
age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag,
|
||||
radio_status, regional, regional2, spare, spare2,
|
||||
ais_version, position_fix_type, dte, band_flag,
|
||||
received_date, collected_at, created_at, updated_at,
|
||||
tonnes_cargo, in_sts, on_berth, dwt, anomalous,
|
||||
destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received,
|
||||
lpc_code, message_type, "source", station_id, zone_id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, NOW(), NOW(),
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET
|
||||
imo = EXCLUDED.imo,
|
||||
name = EXCLUDED.name,
|
||||
callsign = EXCLUDED.callsign,
|
||||
vessel_type = EXCLUDED.vessel_type,
|
||||
extra_info = EXCLUDED.extra_info,
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
geom = EXCLUDED.geom,
|
||||
heading = EXCLUDED.heading,
|
||||
sog = EXCLUDED.sog,
|
||||
cog = EXCLUDED.cog,
|
||||
rot = EXCLUDED.rot,
|
||||
length = EXCLUDED.length,
|
||||
width = EXCLUDED.width,
|
||||
draught = EXCLUDED.draught,
|
||||
length_bow = EXCLUDED.length_bow,
|
||||
length_stern = EXCLUDED.length_stern,
|
||||
width_port = EXCLUDED.width_port,
|
||||
width_starboard = EXCLUDED.width_starboard,
|
||||
destination = EXCLUDED.destination,
|
||||
eta = EXCLUDED.eta,
|
||||
status = EXCLUDED.status,
|
||||
age_minutes = EXCLUDED.age_minutes,
|
||||
position_accuracy = EXCLUDED.position_accuracy,
|
||||
timestamp_utc = EXCLUDED.timestamp_utc,
|
||||
repeat_indicator = EXCLUDED.repeat_indicator,
|
||||
raim_flag = EXCLUDED.raim_flag,
|
||||
radio_status = EXCLUDED.radio_status,
|
||||
regional = EXCLUDED.regional,
|
||||
regional2 = EXCLUDED.regional2,
|
||||
spare = EXCLUDED.spare,
|
||||
spare2 = EXCLUDED.spare2,
|
||||
ais_version = EXCLUDED.ais_version,
|
||||
position_fix_type = EXCLUDED.position_fix_type,
|
||||
dte = EXCLUDED.dte,
|
||||
band_flag = EXCLUDED.band_flag,
|
||||
received_date = EXCLUDED.received_date,
|
||||
collected_at = EXCLUDED.collected_at,
|
||||
updated_at = NOW(),
|
||||
tonnes_cargo = EXCLUDED.tonnes_cargo,
|
||||
in_sts = EXCLUDED.in_sts,
|
||||
on_berth = EXCLUDED.on_berth,
|
||||
dwt = EXCLUDED.dwt,
|
||||
anomalous = EXCLUDED.anomalous,
|
||||
destination_port_id = EXCLUDED.destination_port_id,
|
||||
destination_tidied = EXCLUDED.destination_tidied,
|
||||
destination_unlocode = EXCLUDED.destination_unlocode,
|
||||
imo_verified = EXCLUDED.imo_verified,
|
||||
last_static_update_received = EXCLUDED.last_static_update_received,
|
||||
lpc_code = EXCLUDED.lpc_code,
|
||||
message_type = EXCLUDED.message_type,
|
||||
"source" = EXCLUDED."source",
|
||||
station_id = EXCLUDED.station_id,
|
||||
zone_id = EXCLUDED.zone_id
|
||||
""";
|
||||
|
||||
// ==================== RowMapper ====================
|
||||
|
||||
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
|
||||
.mmsi(rs.getLong("mmsi"))
|
||||
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
|
||||
.imo(rs.getObject("imo", Long.class))
|
||||
.name(rs.getString("name"))
|
||||
.callsign(rs.getString("callsign"))
|
||||
.vesselType(rs.getString("vessel_type"))
|
||||
.extraInfo(rs.getString("extra_info"))
|
||||
.lat(rs.getObject("lat", Double.class))
|
||||
.lon(rs.getObject("lon", Double.class))
|
||||
.heading(rs.getObject("heading", Double.class))
|
||||
.sog(rs.getObject("sog", Double.class))
|
||||
.cog(rs.getObject("cog", Double.class))
|
||||
.rot(rs.getObject("rot", Integer.class))
|
||||
.length(rs.getObject("length", Integer.class))
|
||||
.width(rs.getObject("width", Integer.class))
|
||||
.draught(rs.getObject("draught", Double.class))
|
||||
.lengthBow(rs.getObject("length_bow", Integer.class))
|
||||
.lengthStern(rs.getObject("length_stern", Integer.class))
|
||||
.widthPort(rs.getObject("width_port", Integer.class))
|
||||
.widthStarboard(rs.getObject("width_starboard", Integer.class))
|
||||
.destination(rs.getString("destination"))
|
||||
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
|
||||
.status(rs.getString("status"))
|
||||
.ageMinutes(rs.getObject("age_minutes", Double.class))
|
||||
.positionAccuracy(rs.getObject("position_accuracy", Integer.class))
|
||||
.timestampUtc(rs.getObject("timestamp_utc", Integer.class))
|
||||
.repeatIndicator(rs.getObject("repeat_indicator", Integer.class))
|
||||
.raimFlag(rs.getObject("raim_flag", Integer.class))
|
||||
.radioStatus(rs.getObject("radio_status", Integer.class))
|
||||
.regional(rs.getObject("regional", Integer.class))
|
||||
.regional2(rs.getObject("regional2", Integer.class))
|
||||
.spare(rs.getObject("spare", Integer.class))
|
||||
.spare2(rs.getObject("spare2", Integer.class))
|
||||
.aisVersion(rs.getObject("ais_version", Integer.class))
|
||||
.positionFixType(rs.getObject("position_fix_type", Integer.class))
|
||||
.dte(rs.getObject("dte", Integer.class))
|
||||
.bandFlag(rs.getObject("band_flag", Integer.class))
|
||||
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
|
||||
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
|
||||
.tonnesCargo(rs.getObject("tonnes_cargo", Integer.class))
|
||||
.inSTS(rs.getObject("in_sts", Integer.class))
|
||||
.onBerth(rs.getObject("on_berth", Boolean.class))
|
||||
.dwt(rs.getObject("dwt", Integer.class))
|
||||
.anomalous(rs.getString("anomalous"))
|
||||
.destinationPortID(rs.getObject("destination_port_id", Integer.class))
|
||||
.destinationTidied(rs.getString("destination_tidied"))
|
||||
.destinationUNLOCODE(rs.getString("destination_unlocode"))
|
||||
.imoVerified(rs.getString("imo_verified"))
|
||||
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
|
||||
.lpcCode(rs.getObject("lpc_code", Integer.class))
|
||||
.messageType(rs.getObject("message_type", Integer.class))
|
||||
.source(rs.getString("source"))
|
||||
.stationId(rs.getString("station_id"))
|
||||
.zoneId(rs.getObject("zone_id", Double.class))
|
||||
.build();
|
||||
|
||||
// ==================== Repository Methods ====================
|
||||
|
||||
@Override
|
||||
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
|
||||
String sql = "SELECT * FROM " + TABLE_NAME + " WHERE mmsi = ? AND message_timestamp = ?";
|
||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AisTargetEntity> findLatestByMmsi(Long mmsi) {
|
||||
String sql = """
|
||||
SELECT * FROM %s
|
||||
WHERE mmsi = ?
|
||||
ORDER BY message_timestamp DESC
|
||||
LIMIT 1
|
||||
""".formatted(TABLE_NAME);
|
||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// DISTINCT ON을 사용하여 각 MMSI별 최신 레코드 조회
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM %s
|
||||
WHERE mmsi = ANY(?)
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
""".formatted(TABLE_NAME);
|
||||
|
||||
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
|
||||
String sql = """
|
||||
SELECT * FROM %s
|
||||
WHERE mmsi = ?
|
||||
AND message_timestamp BETWEEN ? AND ?
|
||||
ORDER BY message_timestamp ASC
|
||||
""".formatted(TABLE_NAME);
|
||||
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findByTimeRangeAndArea(
|
||||
OffsetDateTime start,
|
||||
OffsetDateTime end,
|
||||
Double centerLon,
|
||||
Double centerLat,
|
||||
Double radiusMeters
|
||||
) {
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM %s
|
||||
WHERE message_timestamp BETWEEN ? AND ?
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
|
||||
?
|
||||
)
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
""".formatted(TABLE_NAME);
|
||||
|
||||
return jdbcTemplate.query(sql, rowMapper,
|
||||
toTimestamp(start), toTimestamp(end),
|
||||
centerLon, centerLat, radiusMeters);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void batchUpsert(List<AisTargetEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
|
||||
|
||||
jdbcTemplate.batchUpdate(UPSERT_SQL, entities, 1000, (ps, entity) -> {
|
||||
int idx = 1;
|
||||
// PK
|
||||
ps.setLong(idx++, entity.getMmsi());
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getMessageTimestamp()));
|
||||
// 선박 식별 정보
|
||||
ps.setObject(idx++, entity.getImo());
|
||||
ps.setString(idx++, truncate(entity.getName(), 100));
|
||||
ps.setString(idx++, truncate(entity.getCallsign(), 20));
|
||||
ps.setString(idx++, truncate(entity.getVesselType(), 50));
|
||||
ps.setString(idx++, truncate(entity.getExtraInfo(), 100));
|
||||
// 위치 정보
|
||||
ps.setObject(idx++, entity.getLat());
|
||||
ps.setObject(idx++, entity.getLon());
|
||||
// geom용 lon, lat
|
||||
ps.setObject(idx++, entity.getLon());
|
||||
ps.setObject(idx++, entity.getLat());
|
||||
// 항해 정보
|
||||
ps.setObject(idx++, entity.getHeading());
|
||||
ps.setObject(idx++, entity.getSog());
|
||||
ps.setObject(idx++, entity.getCog());
|
||||
ps.setObject(idx++, entity.getRot());
|
||||
// 선박 제원
|
||||
ps.setObject(idx++, entity.getLength());
|
||||
ps.setObject(idx++, entity.getWidth());
|
||||
ps.setObject(idx++, entity.getDraught());
|
||||
ps.setObject(idx++, entity.getLengthBow());
|
||||
ps.setObject(idx++, entity.getLengthStern());
|
||||
ps.setObject(idx++, entity.getWidthPort());
|
||||
ps.setObject(idx++, entity.getWidthStarboard());
|
||||
// 목적지 정보
|
||||
ps.setString(idx++, truncate(entity.getDestination(), 200));
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getEta()));
|
||||
ps.setString(idx++, truncate(entity.getStatus(), 50));
|
||||
// AIS 메시지 정보
|
||||
ps.setObject(idx++, entity.getAgeMinutes());
|
||||
ps.setObject(idx++, entity.getPositionAccuracy());
|
||||
ps.setObject(idx++, entity.getTimestampUtc());
|
||||
ps.setObject(idx++, entity.getRepeatIndicator());
|
||||
ps.setObject(idx++, entity.getRaimFlag());
|
||||
ps.setObject(idx++, entity.getRadioStatus());
|
||||
ps.setObject(idx++, entity.getRegional());
|
||||
ps.setObject(idx++, entity.getRegional2());
|
||||
ps.setObject(idx++, entity.getSpare());
|
||||
ps.setObject(idx++, entity.getSpare2());
|
||||
ps.setObject(idx++, entity.getAisVersion());
|
||||
ps.setObject(idx++, entity.getPositionFixType());
|
||||
ps.setObject(idx++, entity.getDte());
|
||||
ps.setObject(idx++, entity.getBandFlag());
|
||||
// 타임스탬프
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getReceivedDate()));
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getCollectedAt()));
|
||||
// TargetEnhanced 컬럼 추가
|
||||
ps.setObject(idx++, entity.getTonnesCargo());
|
||||
ps.setObject(idx++, entity.getInSTS());
|
||||
ps.setObject(idx++, entity.getOnBerth());
|
||||
ps.setObject(idx++, entity.getDwt());
|
||||
ps.setObject(idx++, entity.getAnomalous());
|
||||
ps.setObject(idx++, entity.getDestinationPortID());
|
||||
ps.setObject(idx++, entity.getDestinationTidied());
|
||||
ps.setObject(idx++, entity.getDestinationUNLOCODE());
|
||||
ps.setObject(idx++, entity.getImoVerified());
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getLastStaticUpdateReceived()));
|
||||
ps.setObject(idx++, entity.getLpcCode());
|
||||
ps.setObject(idx++, entity.getMessageType());
|
||||
ps.setObject(idx++, entity.getSource());
|
||||
ps.setObject(idx++, entity.getStationId());
|
||||
ps.setObject(idx++, entity.getZoneId());
|
||||
});
|
||||
|
||||
log.info("AIS Target 배치 UPSERT 완료: {} 건", entities.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
String sql = "SELECT COUNT(*) FROM " + TABLE_NAME;
|
||||
Long count = jdbcTemplate.queryForObject(sql, Long.class);
|
||||
return count != null ? count : 0L;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int deleteOlderThan(OffsetDateTime threshold) {
|
||||
String sql = "DELETE FROM " + TABLE_NAME + " WHERE message_timestamp < ?";
|
||||
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
|
||||
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
private Timestamp toTimestamp(OffsetDateTime odt) {
|
||||
return odt != null ? Timestamp.from(odt.toInstant()) : null;
|
||||
}
|
||||
|
||||
private OffsetDateTime toOffsetDateTime(Timestamp ts) {
|
||||
return ts != null ? ts.toInstant().atOffset(ZoneOffset.UTC) : null;
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null) return null;
|
||||
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.writer;
|
||||
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AIS Target 데이터 Writer (캐시 전용)
|
||||
*
|
||||
* 동작:
|
||||
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
||||
* 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
|
||||
*
|
||||
* 참고:
|
||||
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
|
||||
* - 이 Writer는 캐시 업데이트만 담당
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
||||
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final AisClassTypeClassifier classTypeClassifier;
|
||||
|
||||
public AisTargetDataWriter(
|
||||
AisTargetCacheManager cacheManager,
|
||||
AisClassTypeClassifier classTypeClassifier) {
|
||||
super("AisTarget");
|
||||
this.cacheManager = cacheManager;
|
||||
this.classTypeClassifier = classTypeClassifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<AisTargetEntity> items) throws Exception {
|
||||
log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size());
|
||||
|
||||
// 1. ClassType 분류 (캐시 저장 전에 분류)
|
||||
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
||||
classTypeClassifier.classifyAll(items);
|
||||
|
||||
// 2. 캐시 업데이트 (classType, core20Mmsi 포함)
|
||||
cacheManager.putAll(items);
|
||||
|
||||
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
|
||||
items.size(), cacheManager.size());
|
||||
}
|
||||
}
|
||||
272
src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java
vendored
Normal file
272
src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java
vendored
Normal file
@ -0,0 +1,272 @@
|
||||
package com.snp.batch.jobs.aistarget.cache;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.github.benmanes.caffeine.cache.RemovalCause;
|
||||
import com.github.benmanes.caffeine.cache.stats.CacheStats;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* AIS Target 캐시 매니저 (Caffeine 기반)
|
||||
*
|
||||
* Caffeine 캐시의 장점:
|
||||
* - 고성능: ConcurrentHashMap 대비 우수한 성능
|
||||
* - 자동 만료: expireAfterWrite/expireAfterAccess 내장
|
||||
* - 최대 크기 제한: maximumSize + LRU/LFU 자동 정리
|
||||
* - 통계: 히트율, 미스율, 로드 시간 등 상세 통계
|
||||
* - 비동기 지원: AsyncCache로 비동기 로딩 가능
|
||||
*
|
||||
* 동작:
|
||||
* - 배치 Writer에서 DB 저장과 동시에 캐시 업데이트
|
||||
* - API 조회 시 캐시 우선 조회
|
||||
* - 캐시 미스 시 DB 조회 후 캐시 갱신
|
||||
* - TTL: 마지막 쓰기 이후 N분 뒤 자동 만료
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetCacheManager {
|
||||
|
||||
private Cache<Long, AisTargetEntity> cache;
|
||||
|
||||
@Value("${app.batch.ais-target-cache.ttl-minutes:5}")
|
||||
private long ttlMinutes;
|
||||
|
||||
@Value("${app.batch.ais-target-cache.max-size:100000}")
|
||||
private int maxSize;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.cache = Caffeine.newBuilder()
|
||||
// 최대 캐시 크기 (초과 시 LRU 방식으로 정리)
|
||||
.maximumSize(maxSize)
|
||||
// 마지막 쓰기 이후 TTL (데이터 업데이트 시 자동 갱신)
|
||||
.expireAfterWrite(ttlMinutes, TimeUnit.MINUTES)
|
||||
// 통계 수집 활성화
|
||||
.recordStats()
|
||||
// 제거 리스너 (디버깅/모니터링용)
|
||||
.removalListener((Long key, AisTargetEntity value, RemovalCause cause) -> {
|
||||
if (cause != RemovalCause.REPLACED) {
|
||||
log.trace("캐시 제거 - MMSI: {}, 원인: {}", key, cause);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
log.info("AIS Target Caffeine 캐시 초기화 - TTL: {}분, 최대 크기: {}", ttlMinutes, maxSize);
|
||||
}
|
||||
|
||||
// ==================== 단건 조회/업데이트 ====================
|
||||
|
||||
/**
|
||||
* 캐시에서 최신 위치 조회
|
||||
*
|
||||
* @param mmsi MMSI 번호
|
||||
* @return 캐시된 데이터 (없으면 Optional.empty)
|
||||
*/
|
||||
public Optional<AisTargetEntity> get(Long mmsi) {
|
||||
AisTargetEntity entity = cache.getIfPresent(mmsi);
|
||||
return Optional.ofNullable(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에 데이터 저장/업데이트
|
||||
* - 기존 데이터보다 최신인 경우에만 업데이트
|
||||
* - 업데이트 시 TTL 자동 갱신 (expireAfterWrite)
|
||||
*
|
||||
* @param entity AIS Target 엔티티
|
||||
*/
|
||||
public void put(AisTargetEntity entity) {
|
||||
if (entity == null || entity.getMmsi() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long mmsi = entity.getMmsi();
|
||||
AisTargetEntity existing = cache.getIfPresent(mmsi);
|
||||
|
||||
// 기존 데이터보다 최신인 경우에만 업데이트
|
||||
if (existing == null || isNewer(entity, existing)) {
|
||||
cache.put(mmsi, entity);
|
||||
log.trace("캐시 저장 - MMSI: {}", mmsi);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 배치 조회/업데이트 ====================
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회
|
||||
*
|
||||
* @param mmsiList MMSI 목록
|
||||
* @return 캐시에서 찾은 데이터 맵 (MMSI -> Entity)
|
||||
*/
|
||||
public Map<Long, AisTargetEntity> getAll(List<Long> mmsiList) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
// Caffeine의 getAllPresent는 존재하는 키만 반환
|
||||
Map<Long, AisTargetEntity> result = cache.getAllPresent(mmsiList);
|
||||
|
||||
log.debug("캐시 배치 조회 - 요청: {}, 히트: {}",
|
||||
mmsiList.size(), result.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 데이터 일괄 저장/업데이트 (배치 Writer에서 호출)
|
||||
*
|
||||
* @param entities AIS Target 엔티티 목록
|
||||
*/
|
||||
public void putAll(List<AisTargetEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
|
||||
for (AisTargetEntity entity : entities) {
|
||||
if (entity == null || entity.getMmsi() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Long mmsi = entity.getMmsi();
|
||||
AisTargetEntity existing = cache.getIfPresent(mmsi);
|
||||
|
||||
// 기존 데이터보다 최신인 경우에만 업데이트
|
||||
if (existing == null || isNewer(entity, existing)) {
|
||||
cache.put(mmsi, entity);
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("캐시 배치 업데이트 - 입력: {}, 업데이트: {}, 스킵: {}, 현재 크기: {}",
|
||||
entities.size(), updated, skipped, cache.estimatedSize());
|
||||
}
|
||||
|
||||
// ==================== 캐시 관리 ====================
|
||||
|
||||
/**
|
||||
* 특정 MMSI 캐시 삭제
|
||||
*/
|
||||
public void evict(Long mmsi) {
|
||||
cache.invalidate(mmsi);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 MMSI 캐시 삭제
|
||||
*/
|
||||
public void evictAll(List<Long> mmsiList) {
|
||||
cache.invalidateAll(mmsiList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 캐시 삭제
|
||||
*/
|
||||
public void clear() {
|
||||
long size = cache.estimatedSize();
|
||||
cache.invalidateAll();
|
||||
log.info("캐시 전체 삭제 - {} 건", size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐시 크기 (추정값)
|
||||
*/
|
||||
public long size() {
|
||||
return cache.estimatedSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 정리 (만료된 엔트리 즉시 제거)
|
||||
*/
|
||||
public void cleanup() {
|
||||
cache.cleanUp();
|
||||
}
|
||||
|
||||
// ==================== 통계 ====================
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회
|
||||
*/
|
||||
public Map<String, Object> getStats() {
|
||||
CacheStats stats = cache.stats();
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("estimatedSize", cache.estimatedSize());
|
||||
result.put("maxSize", maxSize);
|
||||
result.put("ttlMinutes", ttlMinutes);
|
||||
result.put("hitCount", stats.hitCount());
|
||||
result.put("missCount", stats.missCount());
|
||||
result.put("hitRate", String.format("%.2f%%", stats.hitRate() * 100));
|
||||
result.put("missRate", String.format("%.2f%%", stats.missRate() * 100));
|
||||
result.put("evictionCount", stats.evictionCount());
|
||||
result.put("loadCount", stats.loadCount());
|
||||
result.put("averageLoadPenalty", String.format("%.2fms", stats.averageLoadPenalty() / 1_000_000.0));
|
||||
result.put("utilizationPercent", String.format("%.2f%%", (cache.estimatedSize() * 100.0 / maxSize)));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 통계 조회 (Caffeine CacheStats 원본)
|
||||
*/
|
||||
public CacheStats getCacheStats() {
|
||||
return cache.stats();
|
||||
}
|
||||
|
||||
// ==================== 전체 데이터 조회 (공간 필터링용) ====================
|
||||
|
||||
/**
|
||||
* 캐시의 모든 데이터 조회 (공간 필터링용)
|
||||
* 주의: 대용량 데이터이므로 신중하게 사용
|
||||
*
|
||||
* @return 캐시된 모든 엔티티
|
||||
*/
|
||||
public Collection<AisTargetEntity> getAllValues() {
|
||||
return cache.asMap().values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 범위 내 데이터 필터링
|
||||
*
|
||||
* @param minutes 최근 N분
|
||||
* @return 시간 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> getByTimeRange(int minutes) {
|
||||
java.time.OffsetDateTime threshold = java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
|
||||
.minusMinutes(minutes);
|
||||
|
||||
return cache.asMap().values().stream()
|
||||
.filter(entity -> entity.getMessageTimestamp() != null)
|
||||
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
/**
|
||||
* 새 데이터가 기존 데이터보다 최신인지 확인
|
||||
*/
|
||||
private boolean isNewer(AisTargetEntity newEntity, AisTargetEntity existing) {
|
||||
OffsetDateTime newTimestamp = newEntity.getMessageTimestamp();
|
||||
OffsetDateTime existingTimestamp = existing.getMessageTimestamp();
|
||||
|
||||
if (newTimestamp == null) {
|
||||
return false;
|
||||
}
|
||||
if (existingTimestamp == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return newTimestamp.isAfter(existingTimestamp);
|
||||
}
|
||||
}
|
||||
229
src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java
vendored
Normal file
229
src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java
vendored
Normal file
@ -0,0 +1,229 @@
|
||||
package com.snp.batch.jobs.aistarget.cache;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.NumericCondition;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AIS Target 필터링 유틸리티
|
||||
*
|
||||
* 캐시 데이터에 대한 조건 필터링 수행
|
||||
* - SOG, COG, Heading: 숫자 범위 조건
|
||||
* - Destination: 문자열 부분 일치
|
||||
* - Status: 다중 선택 일치
|
||||
* - ClassType: 선박 클래스 타입 (A/B)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetFilterUtil {
|
||||
|
||||
/**
|
||||
* 필터 조건에 따라 엔티티 목록 필터링
|
||||
*
|
||||
* @param entities 원본 엔티티 목록
|
||||
* @param request 필터 조건
|
||||
* @return 필터링된 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filter(List<AisTargetEntity> entities, AisTargetFilterRequest request) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (!request.hasAnyFilter()) {
|
||||
return entities;
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> matchesSog(entity, request))
|
||||
.filter(entity -> matchesCog(entity, request))
|
||||
.filter(entity -> matchesHeading(entity, request))
|
||||
.filter(entity -> matchesDestination(entity, request))
|
||||
.filter(entity -> matchesStatus(entity, request))
|
||||
.filter(entity -> matchesClassType(entity, request))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* AisTargetSearchRequest 기반 ClassType 필터링
|
||||
*
|
||||
* @param entities 원본 엔티티 목록
|
||||
* @param request 검색 조건
|
||||
* @return 필터링된 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByClassType(List<AisTargetEntity> entities, AisTargetSearchRequest request) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!request.hasClassTypeFilter()) {
|
||||
return entities;
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> matchesClassType(entity, request.getClassType()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("ClassType 필터링 완료 - 입력: {}, 결과: {}, 필터: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), request.getClassType(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 classType으로 직접 필터링
|
||||
*/
|
||||
private boolean matchesClassType(AisTargetEntity entity, String classTypeFilter) {
|
||||
if (classTypeFilter == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityClassType = entity.getClassType();
|
||||
|
||||
// classType이 미분류(null)인 데이터 처리
|
||||
if (entityClassType == null) {
|
||||
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
|
||||
return "B".equalsIgnoreCase(classTypeFilter);
|
||||
}
|
||||
|
||||
return classTypeFilter.equalsIgnoreCase(entityClassType);
|
||||
}
|
||||
|
||||
/**
|
||||
* SOG (속도) 조건 매칭
|
||||
*/
|
||||
private boolean matchesSog(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasSogFilter()) {
|
||||
return true; // 필터 없으면 통과
|
||||
}
|
||||
|
||||
NumericCondition condition = NumericCondition.fromString(request.getSogCondition());
|
||||
if (condition == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return condition.matches(
|
||||
entity.getSog(),
|
||||
request.getSogValue(),
|
||||
request.getSogMin(),
|
||||
request.getSogMax()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* COG (침로) 조건 매칭
|
||||
*/
|
||||
private boolean matchesCog(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasCogFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
NumericCondition condition = NumericCondition.fromString(request.getCogCondition());
|
||||
if (condition == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return condition.matches(
|
||||
entity.getCog(),
|
||||
request.getCogValue(),
|
||||
request.getCogMin(),
|
||||
request.getCogMax()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading (선수방위) 조건 매칭
|
||||
*/
|
||||
private boolean matchesHeading(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasHeadingFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
NumericCondition condition = NumericCondition.fromString(request.getHeadingCondition());
|
||||
if (condition == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return condition.matches(
|
||||
entity.getHeading(),
|
||||
request.getHeadingValue(),
|
||||
request.getHeadingMin(),
|
||||
request.getHeadingMax()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destination (목적지) 조건 매칭 - 부분 일치, 대소문자 무시
|
||||
*/
|
||||
private boolean matchesDestination(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasDestinationFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityDestination = entity.getDestination();
|
||||
if (entityDestination == null || entityDestination.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entityDestination.toUpperCase().contains(request.getDestination().toUpperCase().trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Status (항행상태) 조건 매칭 - 다중 선택 중 하나라도 일치
|
||||
*/
|
||||
private boolean matchesStatus(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasStatusFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityStatus = entity.getStatus();
|
||||
if (entityStatus == null || entityStatus.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// statusList에 포함되어 있으면 통과
|
||||
return request.getStatusList().stream()
|
||||
.anyMatch(status -> entityStatus.equalsIgnoreCase(status.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassType (선박 클래스 타입) 조건 매칭
|
||||
*
|
||||
* - A: Core20에 등록된 선박
|
||||
* - B: Core20 미등록 선박
|
||||
* - 필터 미지정: 전체 통과
|
||||
* - classType이 null인 데이터: B 필터에만 포함 (보수적 접근)
|
||||
*/
|
||||
private boolean matchesClassType(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasClassTypeFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityClassType = entity.getClassType();
|
||||
|
||||
// classType이 미분류(null)인 데이터 처리
|
||||
if (entityClassType == null) {
|
||||
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
|
||||
return "B".equalsIgnoreCase(request.getClassType());
|
||||
}
|
||||
|
||||
return request.getClassType().equalsIgnoreCase(entityClassType);
|
||||
}
|
||||
}
|
||||
317
src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java
vendored
Normal file
317
src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java
vendored
Normal file
@ -0,0 +1,317 @@
|
||||
package com.snp.batch.jobs.aistarget.cache;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.*;
|
||||
import org.locationtech.jts.geom.impl.CoordinateArraySequence;
|
||||
import org.locationtech.jts.operation.distance.DistanceOp;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 공간 필터링 유틸리티 (JTS 기반)
|
||||
*
|
||||
* 지원 기능:
|
||||
* - 원형 범위 내 선박 필터링 (Point + Radius)
|
||||
* - 폴리곤 범위 내 선박 필터링 (Polygon)
|
||||
* - 거리 계산 (Haversine 공식 - 지구 곡률 고려)
|
||||
*
|
||||
* 성능:
|
||||
* - 25만 건 필터링: 약 50-100ms (병렬 처리 시)
|
||||
* - 단순 거리 계산은 JTS 없이 Haversine으로 처리 (더 빠름)
|
||||
* - 복잡한 폴리곤은 JTS 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SpatialFilterUtil {
|
||||
|
||||
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(), 4326);
|
||||
|
||||
// 지구 반경 (미터)
|
||||
private static final double EARTH_RADIUS_METERS = 6_371_000;
|
||||
|
||||
// ==================== 원형 범위 필터링 ====================
|
||||
|
||||
/**
|
||||
* 원형 범위 내 선박 필터링 (Haversine 거리 계산 - 빠름)
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param centerLon 중심 경도
|
||||
* @param centerLat 중심 위도
|
||||
* @param radiusMeters 반경 (미터)
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByCircle(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double centerLon,
|
||||
double centerLat,
|
||||
double radiusMeters) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 병렬 스트림으로 필터링 (대용량 데이터 최적화)
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.filter(entity -> {
|
||||
double distance = haversineDistance(
|
||||
centerLat, centerLon,
|
||||
entity.getLat(), entity.getLon()
|
||||
);
|
||||
return distance <= radiusMeters;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("원형 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원형 범위 내 선박 필터링 + 거리 정보 포함
|
||||
*/
|
||||
public List<EntityWithDistance> filterByCircleWithDistance(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double centerLon,
|
||||
double centerLat,
|
||||
double radiusMeters) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.map(entity -> {
|
||||
double distance = haversineDistance(
|
||||
centerLat, centerLon,
|
||||
entity.getLat(), entity.getLon()
|
||||
);
|
||||
return new EntityWithDistance(entity, distance);
|
||||
})
|
||||
.filter(ewd -> ewd.getDistanceMeters() <= radiusMeters)
|
||||
.sorted((a, b) -> Double.compare(a.getDistanceMeters(), b.getDistanceMeters()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 폴리곤 범위 필터링 ====================
|
||||
|
||||
/**
|
||||
* 폴리곤 범위 내 선박 필터링 (JTS 사용)
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param polygonCoordinates 폴리곤 좌표 [[lon, lat], [lon, lat], ...] (닫힌 형태)
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByPolygon(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double[][] polygonCoordinates) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
if (polygonCoordinates == null || polygonCoordinates.length < 4) {
|
||||
log.warn("유효하지 않은 폴리곤 좌표 (최소 4개 점 필요)");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// JTS Polygon 생성
|
||||
Polygon polygon = createPolygon(polygonCoordinates);
|
||||
|
||||
if (polygon == null || !polygon.isValid()) {
|
||||
log.warn("유효하지 않은 폴리곤");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 병렬 스트림으로 필터링
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.filter(entity -> {
|
||||
Point point = createPoint(entity.getLon(), entity.getLat());
|
||||
return polygon.contains(point);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("폴리곤 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* WKT(Well-Known Text) 형식 폴리곤으로 필터링
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param wkt WKT 문자열 (예: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByWkt(
|
||||
Collection<AisTargetEntity> entities,
|
||||
String wkt) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
try {
|
||||
Geometry geometry = new org.locationtech.jts.io.WKTReader(GEOMETRY_FACTORY).read(wkt);
|
||||
|
||||
return entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.filter(entity -> {
|
||||
Point point = createPoint(entity.getLon(), entity.getLat());
|
||||
return geometry.contains(point);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("WKT 파싱 실패: {}", wkt, e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GeoJSON 지원 ====================
|
||||
|
||||
/**
|
||||
* GeoJSON 형식 폴리곤으로 필터링
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param geoJsonCoordinates GeoJSON coordinates 배열 [[[lon, lat], ...]]
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByGeoJson(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double[][][] geoJsonCoordinates) {
|
||||
|
||||
if (geoJsonCoordinates == null || geoJsonCoordinates.length == 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// GeoJSON의 첫 번째 링 (외부 경계)
|
||||
return filterByPolygon(entities, geoJsonCoordinates[0]);
|
||||
}
|
||||
|
||||
// ==================== 거리 계산 ====================
|
||||
|
||||
/**
|
||||
* Haversine 공식을 사용한 두 지점 간 거리 계산 (미터)
|
||||
* 지구 곡률을 고려한 정확한 거리 계산
|
||||
*/
|
||||
public double haversineDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
double dLat = Math.toRadians(lat2 - lat1);
|
||||
double dLon = Math.toRadians(lon2 - lon1);
|
||||
|
||||
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return EARTH_RADIUS_METERS * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 엔티티 간 거리 계산 (미터)
|
||||
*/
|
||||
public double calculateDistance(AisTargetEntity entity1, AisTargetEntity entity2) {
|
||||
if (entity1.getLat() == null || entity1.getLon() == null ||
|
||||
entity2.getLat() == null || entity2.getLon() == null) {
|
||||
return Double.MAX_VALUE;
|
||||
}
|
||||
|
||||
return haversineDistance(
|
||||
entity1.getLat(), entity1.getLon(),
|
||||
entity2.getLat(), entity2.getLon()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== JTS 헬퍼 메서드 ====================
|
||||
|
||||
/**
|
||||
* JTS Point 생성
|
||||
*/
|
||||
public Point createPoint(double lon, double lat) {
|
||||
return GEOMETRY_FACTORY.createPoint(new Coordinate(lon, lat));
|
||||
}
|
||||
|
||||
/**
|
||||
* JTS Polygon 생성
|
||||
*/
|
||||
public Polygon createPolygon(double[][] coordinates) {
|
||||
try {
|
||||
Coordinate[] coords = new Coordinate[coordinates.length];
|
||||
for (int i = 0; i < coordinates.length; i++) {
|
||||
coords[i] = new Coordinate(coordinates[i][0], coordinates[i][1]);
|
||||
}
|
||||
|
||||
// 폴리곤이 닫혀있지 않으면 닫기
|
||||
if (!coords[0].equals(coords[coords.length - 1])) {
|
||||
Coordinate[] closedCoords = new Coordinate[coords.length + 1];
|
||||
System.arraycopy(coords, 0, closedCoords, 0, coords.length);
|
||||
closedCoords[coords.length] = coords[0];
|
||||
coords = closedCoords;
|
||||
}
|
||||
|
||||
LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords);
|
||||
return GEOMETRY_FACTORY.createPolygon(ring);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("폴리곤 생성 실패", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 원형 폴리곤 생성 (근사치)
|
||||
*
|
||||
* @param centerLon 중심 경도
|
||||
* @param centerLat 중심 위도
|
||||
* @param radiusMeters 반경 (미터)
|
||||
* @param numPoints 폴리곤 점 개수 (기본: 64)
|
||||
*/
|
||||
public Polygon createCirclePolygon(double centerLon, double centerLat, double radiusMeters, int numPoints) {
|
||||
Coordinate[] coords = new Coordinate[numPoints + 1];
|
||||
|
||||
for (int i = 0; i < numPoints; i++) {
|
||||
double angle = (2 * Math.PI * i) / numPoints;
|
||||
|
||||
// 위도/경도 변환 (근사치)
|
||||
double dLat = (radiusMeters / EARTH_RADIUS_METERS) * (180 / Math.PI);
|
||||
double dLon = dLat / Math.cos(Math.toRadians(centerLat));
|
||||
|
||||
double lat = centerLat + dLat * Math.sin(angle);
|
||||
double lon = centerLon + dLon * Math.cos(angle);
|
||||
|
||||
coords[i] = new Coordinate(lon, lat);
|
||||
}
|
||||
coords[numPoints] = coords[0]; // 닫기
|
||||
|
||||
LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords);
|
||||
return GEOMETRY_FACTORY.createPolygon(ring);
|
||||
}
|
||||
|
||||
// ==================== 내부 클래스 ====================
|
||||
|
||||
/**
|
||||
* 엔티티 + 거리 정보
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.AllArgsConstructor
|
||||
public static class EntityWithDistance {
|
||||
private AisTargetEntity entity;
|
||||
private double distanceMeters;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* AIS Target ClassType 분류기
|
||||
*
|
||||
* 분류 기준:
|
||||
* - Core20 테이블에 IMO가 등록되어 있으면 Class A
|
||||
* - 등록되어 있지 않으면 Class B (기본값)
|
||||
*
|
||||
* 분류 결과:
|
||||
* - classType: "A" 또는 "B"
|
||||
* - core20Mmsi: Core20에 등록된 MMSI (Class A일 때만, nullable)
|
||||
*
|
||||
* 특이 케이스:
|
||||
* 1. IMO가 0이거나 null → Class B
|
||||
* 2. IMO가 7자리가 아닌 의미없는 숫자 → Class B
|
||||
* 3. IMO가 7자리이지만 Core20에 미등록 → Class B
|
||||
* 4. IMO가 Core20에 있지만 MMSI가 null → Class A, core20Mmsi = null
|
||||
*
|
||||
* 향후 제거 가능하도록 독립적인 모듈로 구현
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisClassTypeClassifier {
|
||||
|
||||
/**
|
||||
* 유효한 IMO 패턴 (7자리 숫자)
|
||||
*/
|
||||
private static final Pattern IMO_PATTERN = Pattern.compile("^\\d{7}$");
|
||||
|
||||
private final Core20CacheManager core20CacheManager;
|
||||
|
||||
/**
|
||||
* ClassType 분류 기능 활성화 여부
|
||||
*/
|
||||
@Value("${app.batch.class-type.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
public AisClassTypeClassifier(Core20CacheManager core20CacheManager) {
|
||||
this.core20CacheManager = core20CacheManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 Entity의 ClassType 분류
|
||||
*
|
||||
* @param entity AIS Target Entity
|
||||
*/
|
||||
public void classify(AisTargetEntity entity) {
|
||||
if (!enabled || entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long imo = entity.getImo();
|
||||
|
||||
// 1. IMO가 null이거나 0이면 Class B
|
||||
if (imo == null || imo == 0) {
|
||||
setClassB(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. IMO가 7자리 숫자인지 확인
|
||||
String imoStr = String.valueOf(imo);
|
||||
if (!isValidImo(imoStr)) {
|
||||
setClassB(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Core20 캐시에서 IMO 존재 여부 확인
|
||||
if (core20CacheManager.containsImo(imoStr)) {
|
||||
// Class A - Core20에 등록된 선박
|
||||
entity.setClassType("A");
|
||||
|
||||
// Core20의 MMSI 조회 (nullable - Core20에 MMSI가 없을 수도 있음)
|
||||
Optional<String> core20Mmsi = core20CacheManager.getMmsiByImo(imoStr);
|
||||
entity.setCore20Mmsi(core20Mmsi.orElse(null));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Core20에 없음 - Class B
|
||||
setClassB(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 Entity 일괄 분류
|
||||
*
|
||||
* @param entities AIS Target Entity 목록
|
||||
*/
|
||||
public void classifyAll(List<AisTargetEntity> entities) {
|
||||
if (!enabled || entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int classACount = 0;
|
||||
int classBCount = 0;
|
||||
int classAWithMmsi = 0;
|
||||
int classAWithoutMmsi = 0;
|
||||
|
||||
for (AisTargetEntity entity : entities) {
|
||||
classify(entity);
|
||||
|
||||
if ("A".equals(entity.getClassType())) {
|
||||
classACount++;
|
||||
if (entity.getCore20Mmsi() != null) {
|
||||
classAWithMmsi++;
|
||||
} else {
|
||||
classAWithoutMmsi++;
|
||||
}
|
||||
} else {
|
||||
classBCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("ClassType 분류 완료 - 총: {}, Class A: {} (MMSI있음: {}, MMSI없음: {}), Class B: {}",
|
||||
entities.size(), classACount, classAWithMmsi, classAWithoutMmsi, classBCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class B로 설정 (기본값)
|
||||
*/
|
||||
private void setClassB(AisTargetEntity entity) {
|
||||
entity.setClassType("B");
|
||||
entity.setCore20Mmsi(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 IMO 번호인지 확인 (7자리 숫자)
|
||||
*
|
||||
* @param imo IMO 문자열
|
||||
* @return 유효 여부
|
||||
*/
|
||||
private boolean isValidImo(String imo) {
|
||||
return imo != null && IMO_PATTERN.matcher(imo).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 활성화 여부
|
||||
*/
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core20 캐시 상태 확인
|
||||
*/
|
||||
public boolean isCacheReady() {
|
||||
return core20CacheManager.isLoaded();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Core20 테이블의 IMO → MMSI 매핑 캐시 매니저
|
||||
*
|
||||
* 동작:
|
||||
* - 애플리케이션 시작 시 또는 첫 조회 시 자동 로딩
|
||||
* - 매일 지정된 시간(기본 04:00)에 전체 갱신
|
||||
* - TTL 없음 (명시적 갱신만)
|
||||
*
|
||||
* 데이터 구조:
|
||||
* - Key: IMO/LRNO (7자리 문자열, NOT NULL)
|
||||
* - Value: MMSI (문자열, NULLABLE - 빈 문자열로 저장)
|
||||
*
|
||||
* 특이사항:
|
||||
* - Core20에 IMO는 있지만 MMSI가 null인 경우도 존재
|
||||
* - 이 경우 containsImo()는 true, getMmsiByImo()는 Optional.empty()
|
||||
* - ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열("")을 sentinel 값으로 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class Core20CacheManager {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Core20Properties properties;
|
||||
|
||||
/**
|
||||
* MMSI가 없는 경우를 나타내는 sentinel 값
|
||||
* ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열 사용
|
||||
*/
|
||||
private static final String NO_MMSI = "";
|
||||
|
||||
/**
|
||||
* IMO → MMSI 매핑 캐시
|
||||
* - Key: IMO (NOT NULL)
|
||||
* - Value: MMSI (빈 문자열이면 MMSI 없음)
|
||||
*/
|
||||
private volatile Map<String, String> imoToMmsiMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시간
|
||||
*/
|
||||
private volatile LocalDateTime lastRefreshTime;
|
||||
|
||||
/**
|
||||
* Core20 캐시 갱신 시간 (기본: 04시)
|
||||
*/
|
||||
@Value("${app.batch.class-type.refresh-hour:4}")
|
||||
private int refreshHour;
|
||||
|
||||
public Core20CacheManager(JdbcTemplate jdbcTemplate, Core20Properties properties) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO로 MMSI 조회
|
||||
*
|
||||
* @param imo IMO 번호 (문자열)
|
||||
* @return MMSI 값 (없거나 null/빈 문자열이면 Optional.empty)
|
||||
*/
|
||||
public Optional<String> getMmsiByImo(String imo) {
|
||||
ensureCacheLoaded();
|
||||
|
||||
if (imo == null || !imoToMmsiMap.containsKey(imo)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String mmsi = imoToMmsiMap.get(imo);
|
||||
|
||||
// MMSI가 빈 문자열(NO_MMSI)인 경우
|
||||
if (mmsi == null || mmsi.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(mmsi);
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO 존재 여부만 확인 (MMSI 유무와 무관)
|
||||
* - Core20에 등록된 선박인지 판단하는 용도
|
||||
* - MMSI가 null이어도 IMO가 있으면 true
|
||||
*
|
||||
* @param imo IMO 번호
|
||||
* @return Core20에 등록 여부
|
||||
*/
|
||||
public boolean containsImo(String imo) {
|
||||
ensureCacheLoaded();
|
||||
return imo != null && imoToMmsiMap.containsKey(imo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 전체 갱신 (DB에서 다시 로딩)
|
||||
*/
|
||||
public synchronized void refresh() {
|
||||
log.info("Core20 캐시 갱신 시작 - 테이블: {}", properties.getFullTableName());
|
||||
|
||||
try {
|
||||
String sql = properties.buildSelectSql();
|
||||
log.debug("Core20 조회 SQL: {}", sql);
|
||||
|
||||
Map<String, String> newMap = new ConcurrentHashMap<>();
|
||||
|
||||
jdbcTemplate.query(sql, rs -> {
|
||||
String imo = rs.getString(1);
|
||||
String mmsi = rs.getString(2); // nullable
|
||||
|
||||
if (imo != null && !imo.isBlank()) {
|
||||
// IMO는 trim하여 저장, MMSI는 빈 문자열로 대체 (ConcurrentHashMap은 null 불가)
|
||||
String trimmedImo = imo.trim();
|
||||
String trimmedMmsi = (mmsi != null && !mmsi.isBlank()) ? mmsi.trim() : NO_MMSI;
|
||||
newMap.put(trimmedImo, trimmedMmsi);
|
||||
}
|
||||
});
|
||||
|
||||
this.imoToMmsiMap = newMap;
|
||||
this.lastRefreshTime = LocalDateTime.now();
|
||||
|
||||
// 통계 로깅
|
||||
long withMmsi = newMap.values().stream()
|
||||
.filter(v -> !v.isEmpty())
|
||||
.count();
|
||||
|
||||
log.info("Core20 캐시 갱신 완료 - 총 {} 건 (MMSI 있음: {} 건, MMSI 없음: {} 건)",
|
||||
newMap.size(), withMmsi, newMap.size() - withMmsi);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Core20 캐시 갱신 실패: {}", e.getMessage(), e);
|
||||
// 기존 캐시 유지 (실패해도 서비스 중단 방지)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시가 비어있으면 자동 로딩
|
||||
*/
|
||||
private void ensureCacheLoaded() {
|
||||
if (imoToMmsiMap.isEmpty() && lastRefreshTime == null) {
|
||||
log.warn("Core20 캐시 비어있음 - 자동 로딩 실행");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 시간대에 갱신이 필요한지 확인
|
||||
* - 기본: 04:00 ~ 04:01 사이
|
||||
* - 같은 날 이미 갱신했으면 스킵
|
||||
*
|
||||
* @return 갱신 필요 여부
|
||||
*/
|
||||
public boolean shouldRefresh() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int currentHour = now.getHour();
|
||||
int currentMinute = now.getMinute();
|
||||
|
||||
// 지정된 시간(예: 04:00~04:01) 체크
|
||||
if (currentHour != refreshHour || currentMinute > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오늘 해당 시간에 이미 갱신했으면 스킵
|
||||
if (lastRefreshTime != null &&
|
||||
lastRefreshTime.toLocalDate().equals(now.toLocalDate()) &&
|
||||
lastRefreshTime.getHour() == refreshHour) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐시 크기
|
||||
*/
|
||||
public int size() {
|
||||
return imoToMmsiMap.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시간
|
||||
*/
|
||||
public LocalDateTime getLastRefreshTime() {
|
||||
return lastRefreshTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시가 로드되었는지 확인
|
||||
*/
|
||||
public boolean isLoaded() {
|
||||
return lastRefreshTime != null && !imoToMmsiMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회 (모니터링/디버깅용)
|
||||
*/
|
||||
public Map<String, Object> getStats() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalCount", imoToMmsiMap.size());
|
||||
stats.put("withMmsiCount", imoToMmsiMap.values().stream()
|
||||
.filter(v -> !v.isEmpty()).count());
|
||||
stats.put("withoutMmsiCount", imoToMmsiMap.values().stream()
|
||||
.filter(String::isEmpty).count());
|
||||
stats.put("lastRefreshTime", lastRefreshTime);
|
||||
stats.put("refreshHour", refreshHour);
|
||||
stats.put("tableName", properties.getFullTableName());
|
||||
stats.put("imoColumn", properties.getImoColumn());
|
||||
stats.put("mmsiColumn", properties.getMmsiColumn());
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Core20 테이블 설정 프로퍼티
|
||||
*
|
||||
* 환경별(dev/qa/prod)로 테이블명, 컬럼명이 다를 수 있으므로
|
||||
* 프로파일별 설정 파일에서 지정할 수 있도록 구성
|
||||
*
|
||||
* 사용 예:
|
||||
* - dev: snp_data.core20 (ihslrorimoshipno, maritimemobileserviceidentitymmsinumber)
|
||||
* - prod: new_snp.core20 (lrno, mmsi)
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "app.batch.core20")
|
||||
public class Core20Properties {
|
||||
|
||||
/**
|
||||
* 스키마명 (예: snp_data, new_snp)
|
||||
*/
|
||||
private String schema = "snp_data";
|
||||
|
||||
/**
|
||||
* 테이블명 (예: core20)
|
||||
*/
|
||||
private String table = "core20";
|
||||
|
||||
/**
|
||||
* IMO/LRNO 컬럼명 (PK, NOT NULL)
|
||||
*/
|
||||
private String imoColumn = "ihslrorimoshipno";
|
||||
|
||||
/**
|
||||
* MMSI 컬럼명 (NULLABLE)
|
||||
*/
|
||||
private String mmsiColumn = "maritimemobileserviceidentitymmsinumber";
|
||||
|
||||
/**
|
||||
* 전체 테이블명 반환 (schema.table)
|
||||
*/
|
||||
public String getFullTableName() {
|
||||
if (schema != null && !schema.isBlank()) {
|
||||
return schema + "." + table;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT 쿼리 생성
|
||||
* IMO가 NOT NULL인 레코드만 조회
|
||||
*/
|
||||
public String buildSelectSql() {
|
||||
return String.format(
|
||||
"SELECT %s, %s FROM %s WHERE %s IS NOT NULL",
|
||||
imoColumn, mmsiColumn, getFullTableName(), imoColumn
|
||||
);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void logConfig() {
|
||||
log.info("Core20 설정 로드 - 테이블: {}, IMO컬럼: {}, MMSI컬럼: {}",
|
||||
getFullTableName(), imoColumn, mmsiColumn);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,428 @@
|
||||
package com.snp.batch.jobs.aistarget.web.controller;
|
||||
|
||||
import com.snp.batch.common.web.ApiResponse;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.service.AisTargetService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AIS Target REST API Controller
|
||||
*
|
||||
* 캐시 우선 조회 전략:
|
||||
* - 캐시에서 먼저 조회
|
||||
* - 캐시 미스 시 DB 조회 후 캐시 업데이트
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/ais-target")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AIS Target", description = "AIS 선박 위치 정보 API")
|
||||
public class AisTargetController {
|
||||
|
||||
private final AisTargetService aisTargetService;
|
||||
|
||||
// ==================== 단건 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "MMSI로 최신 위치 조회",
|
||||
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)"
|
||||
)
|
||||
@GetMapping("/{mmsi}")
|
||||
public ResponseEntity<ApiResponse<AisTargetResponseDto>> getLatestByMmsi(
|
||||
@Parameter(description = "MMSI 번호", required = true, example = "440123456")
|
||||
@PathVariable Long mmsi) {
|
||||
log.info("최신 위치 조회 요청 - MMSI: {}", mmsi);
|
||||
|
||||
return aisTargetService.findLatestByMmsi(mmsi)
|
||||
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
// ==================== 다건 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "여러 MMSI의 최신 위치 조회",
|
||||
description = "여러 MMSI의 최신 위치 정보를 일괄 조회합니다 (캐시 우선)"
|
||||
)
|
||||
@PostMapping("/batch")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getLatestByMmsiList(
|
||||
@Parameter(description = "MMSI 번호 목록", required = true)
|
||||
@RequestBody List<Long> mmsiList) {
|
||||
log.info("다건 최신 위치 조회 요청 - 요청 수: {}", mmsiList.size());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.findLatestByMmsiList(mmsiList);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"조회 완료: " + result.size() + "/" + mmsiList.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 검색 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "시간/공간 범위로 선박 검색",
|
||||
description = """
|
||||
시간 범위 (필수) + 공간 범위 (옵션) + 선박 클래스 타입 (옵션)으로 선박을 검색합니다.
|
||||
|
||||
- minutes: 조회 범위 (분, 필수)
|
||||
- centerLon, centerLat: 중심 좌표 (옵션)
|
||||
- radiusMeters: 반경 (미터, 옵션)
|
||||
- classType: 선박 클래스 타입 필터 (A/B, 옵션)
|
||||
|
||||
---
|
||||
## ClassType 설명
|
||||
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
|
||||
---
|
||||
## 응답 필드 설명
|
||||
- **classType**: 선박 클래스 타입 (A/B)
|
||||
- **core20Mmsi**: Core20 테이블의 MMSI 값 (Class A인 경우에만 존재할 수 있음)
|
||||
|
||||
공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다.
|
||||
"""
|
||||
)
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> search(
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
@RequestParam Integer minutes,
|
||||
@Parameter(description = "중심 경도", example = "129.0")
|
||||
@RequestParam(required = false) Double centerLon,
|
||||
@Parameter(description = "중심 위도", example = "35.0")
|
||||
@RequestParam(required = false) Double centerLat,
|
||||
@Parameter(description = "반경 (미터)", example = "50000")
|
||||
@RequestParam(required = false) Double radiusMeters,
|
||||
@Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A")
|
||||
@RequestParam(required = false) String classType) {
|
||||
|
||||
log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}, classType: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters, classType);
|
||||
|
||||
AisTargetSearchRequest request = AisTargetSearchRequest.builder()
|
||||
.minutes(minutes)
|
||||
.centerLon(centerLon)
|
||||
.centerLat(centerLat)
|
||||
.radiusMeters(radiusMeters)
|
||||
.classType(classType)
|
||||
.build();
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.search(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "시간/공간 범위로 선박 검색 (POST)",
|
||||
description = """
|
||||
POST 방식으로 검색 조건을 전달합니다.
|
||||
|
||||
---
|
||||
## 요청 예시
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"centerLon": 129.0,
|
||||
"centerLat": 35.0,
|
||||
"radiusMeters": 50000,
|
||||
"classType": "A"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## ClassType 설명
|
||||
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchPost(
|
||||
@Valid @RequestBody AisTargetSearchRequest request) {
|
||||
log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}",
|
||||
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.search(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 조건 필터 검색 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "항해 조건 필터 검색",
|
||||
description = """
|
||||
속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다.
|
||||
|
||||
---
|
||||
## 조건 타입 및 파라미터 사용법
|
||||
|
||||
| 조건 | 의미 | 사용 파라미터 |
|
||||
|------|------|--------------|
|
||||
| GTE | 이상 (>=) | *Value (예: sogValue) |
|
||||
| GT | 초과 (>) | *Value |
|
||||
| LTE | 이하 (<=) | *Value |
|
||||
| LT | 미만 (<) | *Value |
|
||||
| BETWEEN | 범위 | *Min, *Max (예: sogMin, sogMax) |
|
||||
|
||||
---
|
||||
## 요청 예시
|
||||
|
||||
**예시 1: 단일 값 조건 (속도 10knots 이상)**
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"sogCondition": "GTE",
|
||||
"sogValue": 10.0
|
||||
}
|
||||
```
|
||||
|
||||
**예시 2: 범위 조건 (속도 5~15knots, 침로 90~180도)**
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"sogCondition": "BETWEEN",
|
||||
"sogMin": 5.0,
|
||||
"sogMax": 15.0,
|
||||
"cogCondition": "BETWEEN",
|
||||
"cogMin": 90.0,
|
||||
"cogMax": 180.0
|
||||
}
|
||||
```
|
||||
|
||||
**예시 3: 복합 조건**
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"sogCondition": "GTE",
|
||||
"sogValue": 10.0,
|
||||
"cogCondition": "BETWEEN",
|
||||
"cogMin": 90.0,
|
||||
"cogMax": 180.0,
|
||||
"headingCondition": "LT",
|
||||
"headingValue": 180.0,
|
||||
"destination": "BUSAN",
|
||||
"statusList": ["0", "1", "5"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## 항행상태 코드 (statusList)
|
||||
|
||||
| 코드 | 상태 |
|
||||
|------|------|
|
||||
| 0 | Under way using engine (기관 사용 항해 중) |
|
||||
| 1 | At anchor (정박 중) |
|
||||
| 2 | Not under command (조종불능) |
|
||||
| 3 | Restricted manoeuverability (조종제한) |
|
||||
| 4 | Constrained by her draught (흘수제약) |
|
||||
| 5 | Moored (계류 중) |
|
||||
| 6 | Aground (좌초) |
|
||||
| 7 | Engaged in Fishing (어로 중) |
|
||||
| 8 | Under way sailing (돛 항해 중) |
|
||||
| 9-10 | Reserved for future use |
|
||||
| 11 | Power-driven vessel towing astern |
|
||||
| 12 | Power-driven vessel pushing ahead |
|
||||
| 13 | Reserved for future use |
|
||||
| 14 | AIS-SART, MOB-AIS, EPIRB-AIS |
|
||||
| 15 | Undefined (default) |
|
||||
|
||||
---
|
||||
**참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함).
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search/filter")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByFilter(
|
||||
@Valid @RequestBody AisTargetFilterRequest request) {
|
||||
log.info("필터 검색 요청 - minutes: {}, sog: {}/{}, cog: {}/{}, heading: {}/{}, dest: {}, status: {}",
|
||||
request.getMinutes(),
|
||||
request.getSogCondition(), request.getSogValue(),
|
||||
request.getCogCondition(), request.getCogValue(),
|
||||
request.getHeadingCondition(), request.getHeadingValue(),
|
||||
request.getDestination(),
|
||||
request.getStatusList());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.searchByFilter(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"필터 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 폴리곤 검색 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "폴리곤 범위 내 선박 검색",
|
||||
description = """
|
||||
폴리곤 범위 내 선박을 검색합니다.
|
||||
|
||||
요청 예시:
|
||||
{
|
||||
"minutes": 5,
|
||||
"coordinates": [[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]
|
||||
}
|
||||
|
||||
좌표는 [경도, 위도] 순서이며, 폴리곤은 닫힌 형태여야 합니다 (첫점 = 끝점).
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search/polygon")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByPolygon(
|
||||
@RequestBody PolygonSearchRequest request) {
|
||||
log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
|
||||
request.getMinutes(), request.getCoordinates().length);
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.searchByPolygon(
|
||||
request.getMinutes(),
|
||||
request.getCoordinates()
|
||||
);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"폴리곤 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "WKT 범위 내 선박 검색",
|
||||
description = """
|
||||
WKT(Well-Known Text) 형식으로 정의된 범위 내 선박을 검색합니다.
|
||||
|
||||
요청 예시:
|
||||
{
|
||||
"minutes": 5,
|
||||
"wkt": "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))"
|
||||
}
|
||||
|
||||
지원 형식: POLYGON, MULTIPOLYGON
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search/wkt")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByWkt(
|
||||
@RequestBody WktSearchRequest request) {
|
||||
log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.searchByWkt(
|
||||
request.getMinutes(),
|
||||
request.getWkt()
|
||||
);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"WKT 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "거리 포함 원형 범위 검색",
|
||||
description = """
|
||||
원형 범위 내 선박을 검색하고, 중심점으로부터의 거리 정보를 함께 반환합니다.
|
||||
결과는 거리순으로 정렬됩니다.
|
||||
"""
|
||||
)
|
||||
@GetMapping("/search/with-distance")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetService.AisTargetWithDistanceDto>>> searchWithDistance(
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
@RequestParam Integer minutes,
|
||||
@Parameter(description = "중심 경도", required = true, example = "129.0")
|
||||
@RequestParam Double centerLon,
|
||||
@Parameter(description = "중심 위도", required = true, example = "35.0")
|
||||
@RequestParam Double centerLat,
|
||||
@Parameter(description = "반경 (미터)", required = true, example = "50000")
|
||||
@RequestParam Double radiusMeters) {
|
||||
|
||||
log.info("거리 포함 검색 요청 - minutes: {}, center: ({}, {}), radius: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters);
|
||||
|
||||
List<AisTargetService.AisTargetWithDistanceDto> result =
|
||||
aisTargetService.searchWithDistance(minutes, centerLon, centerLat, radiusMeters);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"거리 포함 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 항적 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "항적 조회",
|
||||
description = "특정 MMSI의 시간 범위 내 항적 (위치 이력)을 조회합니다"
|
||||
)
|
||||
@GetMapping("/{mmsi}/track")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getTrack(
|
||||
@Parameter(description = "MMSI 번호", required = true, example = "440123456")
|
||||
@PathVariable Long mmsi,
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "60")
|
||||
@RequestParam Integer minutes) {
|
||||
log.info("항적 조회 요청 - MMSI: {}, 범위: {}분", mmsi, minutes);
|
||||
|
||||
List<AisTargetResponseDto> track = aisTargetService.getTrack(mmsi, minutes);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"항적 조회 완료: " + track.size() + " 포인트",
|
||||
track
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 캐시 관리 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "캐시 통계 조회",
|
||||
description = "AIS Target 캐시의 현재 상태를 조회합니다"
|
||||
)
|
||||
@GetMapping("/cache/stats")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCacheStats() {
|
||||
Map<String, Object> stats = aisTargetService.getCacheStats();
|
||||
return ResponseEntity.ok(ApiResponse.success(stats));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "캐시 초기화",
|
||||
description = "AIS Target 캐시를 초기화합니다"
|
||||
)
|
||||
@DeleteMapping("/cache")
|
||||
public ResponseEntity<ApiResponse<Void>> clearCache() {
|
||||
log.warn("캐시 초기화 요청");
|
||||
aisTargetService.clearCache();
|
||||
return ResponseEntity.ok(ApiResponse.success("캐시가 초기화되었습니다", null));
|
||||
}
|
||||
|
||||
// ==================== 요청 DTO (내부 클래스) ====================
|
||||
|
||||
/**
|
||||
* 폴리곤 검색 요청 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class PolygonSearchRequest {
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
private int minutes;
|
||||
|
||||
@Parameter(description = "폴리곤 좌표 [[lon, lat], ...]", required = true)
|
||||
private double[][] coordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* WKT 검색 요청 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class WktSearchRequest {
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
private int minutes;
|
||||
|
||||
@Parameter(description = "WKT 문자열", required = true,
|
||||
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
|
||||
private String wkt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AIS Target 필터 검색 요청 DTO
|
||||
*
|
||||
* 조건 타입 (condition):
|
||||
* - GTE: 이상 (>=)
|
||||
* - GT: 초과 (>)
|
||||
* - LTE: 이하 (<=)
|
||||
* - LT: 미만 (<)
|
||||
* - BETWEEN: 범위 (min <= value <= max)
|
||||
*
|
||||
* 모든 필터는 선택사항이며, 미지정 시 해당 필드 전체 포함
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "AIS Target 필터 검색 요청")
|
||||
public class AisTargetFilterRequest {
|
||||
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer minutes;
|
||||
|
||||
// ==================== 속도 (SOG) 필터 ====================
|
||||
@Schema(description = """
|
||||
속도(SOG) 조건 타입
|
||||
- GTE: 이상 (>=) - sogValue 사용
|
||||
- GT: 초과 (>) - sogValue 사용
|
||||
- LTE: 이하 (<=) - sogValue 사용
|
||||
- LT: 미만 (<) - sogValue 사용
|
||||
- BETWEEN: 범위 - sogMin, sogMax 사용
|
||||
""",
|
||||
example = "GTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
|
||||
private String sogCondition;
|
||||
|
||||
@Schema(description = "속도 값 (knots) - GTE/GT/LTE/LT 조건에서 사용", example = "10.0")
|
||||
private Double sogValue;
|
||||
|
||||
@Schema(description = "속도 최소값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "5.0")
|
||||
private Double sogMin;
|
||||
|
||||
@Schema(description = "속도 최대값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "15.0")
|
||||
private Double sogMax;
|
||||
|
||||
// ==================== 침로 (COG) 필터 ====================
|
||||
@Schema(description = """
|
||||
침로(COG) 조건 타입
|
||||
- GTE: 이상 (>=) - cogValue 사용
|
||||
- GT: 초과 (>) - cogValue 사용
|
||||
- LTE: 이하 (<=) - cogValue 사용
|
||||
- LT: 미만 (<) - cogValue 사용
|
||||
- BETWEEN: 범위 - cogMin, cogMax 사용
|
||||
""",
|
||||
example = "BETWEEN", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
|
||||
private String cogCondition;
|
||||
|
||||
@Schema(description = "침로 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "180.0")
|
||||
private Double cogValue;
|
||||
|
||||
@Schema(description = "침로 최소값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "90.0")
|
||||
private Double cogMin;
|
||||
|
||||
@Schema(description = "침로 최대값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "270.0")
|
||||
private Double cogMax;
|
||||
|
||||
// ==================== 선수방위 (Heading) 필터 ====================
|
||||
@Schema(description = """
|
||||
선수방위(Heading) 조건 타입
|
||||
- GTE: 이상 (>=) - headingValue 사용
|
||||
- GT: 초과 (>) - headingValue 사용
|
||||
- LTE: 이하 (<=) - headingValue 사용
|
||||
- LT: 미만 (<) - headingValue 사용
|
||||
- BETWEEN: 범위 - headingMin, headingMax 사용
|
||||
""",
|
||||
example = "LTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
|
||||
private String headingCondition;
|
||||
|
||||
@Schema(description = "선수방위 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "90.0")
|
||||
private Double headingValue;
|
||||
|
||||
@Schema(description = "선수방위 최소값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "0.0")
|
||||
private Double headingMin;
|
||||
|
||||
@Schema(description = "선수방위 최대값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "180.0")
|
||||
private Double headingMax;
|
||||
|
||||
// ==================== 목적지 (Destination) 필터 ====================
|
||||
@Schema(description = "목적지 (부분 일치, 대소문자 무시)", example = "BUSAN")
|
||||
private String destination;
|
||||
|
||||
// ==================== 항행상태 (Status) 필터 ====================
|
||||
@Schema(description = """
|
||||
항행상태 목록 (다중 선택 가능, 미선택 시 전체)
|
||||
- Under way using engine (기관 사용 항해 중)
|
||||
- Under way sailing (돛 항해 중)
|
||||
- Anchored (정박 중)
|
||||
- Moored (계류 중)
|
||||
- Not under command (조종불능)
|
||||
- Restriced manoeuverability (조종제한)
|
||||
- Constrained by draught (흘수제약)
|
||||
- Aground (좌초)
|
||||
- Engaged in fishing (어로 중)
|
||||
- Power Driven Towing Astern (예인선-후방)
|
||||
- Power Driven Towing Alongside (예인선-측방)
|
||||
- AIS Sart (비상위치지시기)
|
||||
- N/A (정보없음)
|
||||
""",
|
||||
example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]")
|
||||
private List<String> statusList;
|
||||
|
||||
// ==================== 선박 클래스 타입 (ClassType) 필터 ====================
|
||||
@Schema(description = """
|
||||
선박 클래스 타입 필터
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
// ==================== 필터 존재 여부 확인 ====================
|
||||
|
||||
public boolean hasSogFilter() {
|
||||
return sogCondition != null && !sogCondition.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasCogFilter() {
|
||||
return cogCondition != null && !cogCondition.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasHeadingFilter() {
|
||||
return headingCondition != null && !headingCondition.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasDestinationFilter() {
|
||||
return destination != null && !destination.trim().isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasStatusFilter() {
|
||||
return statusList != null && !statusList.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasClassTypeFilter() {
|
||||
return classType != null &&
|
||||
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
|
||||
}
|
||||
|
||||
public boolean hasAnyFilter() {
|
||||
return hasSogFilter() || hasCogFilter() || hasHeadingFilter()
|
||||
|| hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* AIS Target API 응답 DTO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "AIS Target 응답")
|
||||
public class AisTargetResponseDto {
|
||||
|
||||
// 선박 식별 정보
|
||||
private Long mmsi;
|
||||
private Long imo;
|
||||
private String name;
|
||||
private String callsign;
|
||||
private String vesselType;
|
||||
|
||||
// 위치 정보
|
||||
private Double lat;
|
||||
private Double lon;
|
||||
|
||||
// 항해 정보
|
||||
private Double heading;
|
||||
private Double sog; // Speed over Ground
|
||||
private Double cog; // Course over Ground
|
||||
private Integer rot; // Rate of Turn
|
||||
|
||||
// 선박 제원
|
||||
private Integer length;
|
||||
private Integer width;
|
||||
private Double draught;
|
||||
|
||||
// 목적지 정보
|
||||
private String destination;
|
||||
private OffsetDateTime eta;
|
||||
private String status;
|
||||
|
||||
// 타임스탬프
|
||||
private OffsetDateTime messageTimestamp;
|
||||
private OffsetDateTime receivedDate;
|
||||
|
||||
// 데이터 소스 (캐시/DB)
|
||||
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
|
||||
private String source;
|
||||
|
||||
// ClassType 분류 정보
|
||||
@Schema(description = """
|
||||
선박 클래스 타입
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
@Schema(description = """
|
||||
Core20 테이블의 MMSI 값
|
||||
- Class A인 경우에만 값이 있을 수 있음
|
||||
- null: Class B 또는 Core20에 MMSI가 미등록된 경우
|
||||
""",
|
||||
example = "440123456", nullable = true)
|
||||
private String core20Mmsi;
|
||||
|
||||
/**
|
||||
* Entity -> DTO 변환
|
||||
*/
|
||||
public static AisTargetResponseDto from(AisTargetEntity entity, String source) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AisTargetResponseDto.builder()
|
||||
.mmsi(entity.getMmsi())
|
||||
.imo(entity.getImo())
|
||||
.name(entity.getName())
|
||||
.callsign(entity.getCallsign())
|
||||
.vesselType(entity.getVesselType())
|
||||
.lat(entity.getLat())
|
||||
.lon(entity.getLon())
|
||||
.heading(entity.getHeading())
|
||||
.sog(entity.getSog())
|
||||
.cog(entity.getCog())
|
||||
.rot(entity.getRot())
|
||||
.length(entity.getLength())
|
||||
.width(entity.getWidth())
|
||||
.draught(entity.getDraught())
|
||||
.destination(entity.getDestination())
|
||||
.eta(entity.getEta())
|
||||
.status(entity.getStatus())
|
||||
.messageTimestamp(entity.getMessageTimestamp())
|
||||
.receivedDate(entity.getReceivedDate())
|
||||
.source(source)
|
||||
.classType(entity.getClassType())
|
||||
.core20Mmsi(entity.getCore20Mmsi())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* AIS Target 검색 요청 DTO
|
||||
*
|
||||
* 필수 파라미터:
|
||||
* - minutes: 분 단위 조회 범위 (1~60)
|
||||
*
|
||||
* 옵션 파라미터:
|
||||
* - centerLon, centerLat, radiusMeters: 공간 범위 필터
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "AIS Target 검색 요청")
|
||||
public class AisTargetSearchRequest {
|
||||
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", required = true)
|
||||
private Integer minutes;
|
||||
|
||||
@Schema(description = "중심 경도 (옵션)", example = "129.0")
|
||||
private Double centerLon;
|
||||
|
||||
@Schema(description = "중심 위도 (옵션)", example = "35.0")
|
||||
private Double centerLat;
|
||||
|
||||
@Schema(description = "반경 (미터, 옵션)", example = "50000")
|
||||
private Double radiusMeters;
|
||||
|
||||
@Schema(description = """
|
||||
선박 클래스 타입 필터
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
/**
|
||||
* 공간 필터 사용 여부
|
||||
*/
|
||||
public boolean hasAreaFilter() {
|
||||
return centerLon != null && centerLat != null && radiusMeters != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassType 필터 사용 여부
|
||||
* - "A" 또는 "B"인 경우에만 true
|
||||
*/
|
||||
public boolean hasClassTypeFilter() {
|
||||
return classType != null &&
|
||||
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
/**
|
||||
* 숫자 비교 조건 열거형
|
||||
*
|
||||
* 사용: SOG, COG, Heading 필터링
|
||||
*/
|
||||
public enum NumericCondition {
|
||||
/**
|
||||
* 이상 (>=)
|
||||
*/
|
||||
GTE,
|
||||
|
||||
/**
|
||||
* 초과 (>)
|
||||
*/
|
||||
GT,
|
||||
|
||||
/**
|
||||
* 이하 (<=)
|
||||
*/
|
||||
LTE,
|
||||
|
||||
/**
|
||||
* 미만 (<)
|
||||
*/
|
||||
LT,
|
||||
|
||||
/**
|
||||
* 범위 (min <= value <= max)
|
||||
*/
|
||||
BETWEEN;
|
||||
|
||||
/**
|
||||
* 문자열을 NumericCondition으로 변환
|
||||
*
|
||||
* @param value 조건 문자열
|
||||
* @return NumericCondition (null이면 null 반환)
|
||||
*/
|
||||
public static NumericCondition fromString(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return NumericCondition.valueOf(value.toUpperCase().trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 값이 조건을 만족하는지 확인
|
||||
*
|
||||
* @param fieldValue 필드 값
|
||||
* @param compareValue 비교 값 (GTE, GT, LTE, LT용)
|
||||
* @param minValue 최소값 (BETWEEN용)
|
||||
* @param maxValue 최대값 (BETWEEN용)
|
||||
* @return 조건 만족 여부
|
||||
*/
|
||||
public boolean matches(Double fieldValue, Double compareValue, Double minValue, Double maxValue) {
|
||||
if (fieldValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return switch (this) {
|
||||
case GTE -> compareValue != null && fieldValue >= compareValue;
|
||||
case GT -> compareValue != null && fieldValue > compareValue;
|
||||
case LTE -> compareValue != null && fieldValue <= compareValue;
|
||||
case LT -> compareValue != null && fieldValue < compareValue;
|
||||
case BETWEEN -> minValue != null && maxValue != null
|
||||
&& fieldValue >= minValue && fieldValue <= maxValue;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 조건절 생성 (DB 쿼리용)
|
||||
*
|
||||
* @param columnName 컬럼명
|
||||
* @return SQL 조건절 문자열
|
||||
*/
|
||||
public String toSqlCondition(String columnName) {
|
||||
return switch (this) {
|
||||
case GTE -> columnName + " >= ?";
|
||||
case GT -> columnName + " > ?";
|
||||
case LTE -> columnName + " <= ?";
|
||||
case LT -> columnName + " < ?";
|
||||
case BETWEEN -> columnName + " BETWEEN ? AND ?";
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,390 @@
|
||||
package com.snp.batch.jobs.aistarget.web.service;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil;
|
||||
import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AIS Target 서비스
|
||||
*
|
||||
* 조회 전략:
|
||||
* 1. 캐시 우선 조회 (Caffeine 캐시)
|
||||
* 2. 캐시 미스 시 DB Fallback
|
||||
* 3. 공간 필터링은 캐시에서 수행 (JTS 기반)
|
||||
*
|
||||
* 성능:
|
||||
* - 캐시 조회: O(1)
|
||||
* - 공간 필터링: O(n) with 병렬 처리 (25만건 ~50-100ms)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AisTargetService {
|
||||
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final SpatialFilterUtil spatialFilterUtil;
|
||||
private final AisTargetFilterUtil filterUtil;
|
||||
|
||||
private static final String SOURCE_CACHE = "cache";
|
||||
private static final String SOURCE_DB = "db";
|
||||
|
||||
// ==================== 단건 조회 ====================
|
||||
|
||||
/**
|
||||
* MMSI로 최신 위치 조회 (캐시 우선)
|
||||
*/
|
||||
public Optional<AisTargetResponseDto> findLatestByMmsi(Long mmsi) {
|
||||
log.debug("최신 위치 조회 - MMSI: {}", mmsi);
|
||||
|
||||
// 1. 캐시 조회
|
||||
Optional<AisTargetEntity> cached = cacheManager.get(mmsi);
|
||||
if (cached.isPresent()) {
|
||||
log.debug("캐시 히트 - MMSI: {}", mmsi);
|
||||
return Optional.of(AisTargetResponseDto.from(cached.get(), SOURCE_CACHE));
|
||||
}
|
||||
|
||||
// 2. DB 조회 (캐시 미스)
|
||||
log.debug("캐시 미스, DB 조회 - MMSI: {}", mmsi);
|
||||
Optional<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsi(mmsi);
|
||||
|
||||
if (fromDb.isPresent()) {
|
||||
// 3. 캐시 업데이트
|
||||
cacheManager.put(fromDb.get());
|
||||
log.debug("DB 조회 성공, 캐시 업데이트 - MMSI: {}", mmsi);
|
||||
return Optional.of(AisTargetResponseDto.from(fromDb.get(), SOURCE_DB));
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// ==================== 다건 조회 ====================
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회 (캐시 우선)
|
||||
*/
|
||||
public List<AisTargetResponseDto> findLatestByMmsiList(List<Long> mmsiList) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
log.debug("다건 최신 위치 조회 - 요청: {} 건", mmsiList.size());
|
||||
|
||||
List<AisTargetResponseDto> result = new ArrayList<>();
|
||||
|
||||
// 1. 캐시에서 조회
|
||||
Map<Long, AisTargetEntity> cachedData = cacheManager.getAll(mmsiList);
|
||||
for (AisTargetEntity entity : cachedData.values()) {
|
||||
result.add(AisTargetResponseDto.from(entity, SOURCE_CACHE));
|
||||
}
|
||||
|
||||
// 2. 캐시 미스 목록
|
||||
List<Long> missedMmsiList = mmsiList.stream()
|
||||
.filter(mmsi -> !cachedData.containsKey(mmsi))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 3. DB에서 캐시 미스 데이터 조회
|
||||
if (!missedMmsiList.isEmpty()) {
|
||||
log.debug("캐시 미스 DB 조회 - {} 건", missedMmsiList.size());
|
||||
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiIn(missedMmsiList);
|
||||
|
||||
for (AisTargetEntity entity : fromDb) {
|
||||
// 캐시 업데이트
|
||||
cacheManager.put(entity);
|
||||
result.add(AisTargetResponseDto.from(entity, SOURCE_DB));
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("조회 완료 - 캐시: {}, DB: {}, 총: {}",
|
||||
cachedData.size(), result.size() - cachedData.size(), result.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 검색 조회 (캐시 기반) ====================
|
||||
|
||||
/**
|
||||
* 시간 범위 + 옵션 공간 범위로 선박 검색 (캐시 우선)
|
||||
*
|
||||
* 전략:
|
||||
* 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
* 2. 공간 필터 있으면 JTS로 필터링
|
||||
* 3. ClassType 필터 있으면 적용
|
||||
* 4. 캐시 데이터가 없으면 DB Fallback
|
||||
*/
|
||||
public List<AisTargetResponseDto> search(AisTargetSearchRequest request) {
|
||||
log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}",
|
||||
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(request.getMinutes());
|
||||
String source = SOURCE_CACHE;
|
||||
|
||||
// 캐시가 비어있으면 DB Fallback
|
||||
if (entities.isEmpty()) {
|
||||
log.debug("캐시 비어있음, DB Fallback");
|
||||
entities = searchFromDb(request);
|
||||
source = SOURCE_DB;
|
||||
|
||||
// DB 결과를 캐시에 저장
|
||||
for (AisTargetEntity entity : entities) {
|
||||
cacheManager.put(entity);
|
||||
}
|
||||
} else if (request.hasAreaFilter()) {
|
||||
// 2. 공간 필터링 (JTS 기반, 병렬 처리)
|
||||
entities = spatialFilterUtil.filterByCircle(
|
||||
entities,
|
||||
request.getCenterLon(),
|
||||
request.getCenterLat(),
|
||||
request.getRadiusMeters()
|
||||
);
|
||||
}
|
||||
|
||||
// 3. ClassType 필터 적용
|
||||
if (request.hasClassTypeFilter()) {
|
||||
entities = filterUtil.filterByClassType(entities, request);
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
|
||||
source, entities.size(), elapsed);
|
||||
|
||||
final String finalSource = source;
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, finalSource))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* DB에서 검색 (Fallback)
|
||||
*/
|
||||
private List<AisTargetEntity> searchFromDb(AisTargetSearchRequest request) {
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
OffsetDateTime start = now.minusMinutes(request.getMinutes());
|
||||
|
||||
if (request.hasAreaFilter()) {
|
||||
return aisTargetRepository.findByTimeRangeAndArea(
|
||||
start, now,
|
||||
request.getCenterLon(),
|
||||
request.getCenterLat(),
|
||||
request.getRadiusMeters()
|
||||
);
|
||||
} else {
|
||||
// 공간 필터 없으면 전체 조회 (주의: 대량 데이터)
|
||||
return aisTargetRepository.findByTimeRangeAndArea(
|
||||
start, now,
|
||||
0.0, 0.0, Double.MAX_VALUE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 조건 필터 검색 ====================
|
||||
|
||||
/**
|
||||
* 항해 조건 필터 검색 (캐시 우선)
|
||||
*
|
||||
* 필터 조건:
|
||||
* - SOG (속도): 이상/초과/이하/미만/범위
|
||||
* - COG (침로): 이상/초과/이하/미만/범위
|
||||
* - Heading (선수방위): 이상/초과/이하/미만/범위
|
||||
* - Destination (목적지): 부분 일치
|
||||
* - Status (항행상태): 다중 선택
|
||||
*
|
||||
* @param request 필터 조건
|
||||
* @return 조건에 맞는 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> searchByFilter(AisTargetFilterRequest request) {
|
||||
log.debug("필터 검색 - minutes: {}, hasFilter: {}",
|
||||
request.getMinutes(), request.hasAnyFilter());
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(request.getMinutes());
|
||||
String source = SOURCE_CACHE;
|
||||
|
||||
// 캐시가 비어있으면 DB Fallback
|
||||
if (entities.isEmpty()) {
|
||||
log.debug("캐시 비어있음, DB Fallback");
|
||||
entities = searchByFilterFromDb(request);
|
||||
source = SOURCE_DB;
|
||||
|
||||
// DB 결과를 캐시에 저장
|
||||
for (AisTargetEntity entity : entities) {
|
||||
cacheManager.put(entity);
|
||||
}
|
||||
|
||||
// DB에서 가져온 후에도 필터 적용 (DB 쿼리는 시간 범위만 적용)
|
||||
entities = filterUtil.filter(entities, request);
|
||||
} else {
|
||||
// 2. 캐시 데이터에 필터 적용
|
||||
entities = filterUtil.filter(entities, request);
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("필터 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
|
||||
source, entities.size(), elapsed);
|
||||
|
||||
final String finalSource = source;
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, finalSource))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* DB에서 필터 검색 (Fallback) - 시간 범위만 적용
|
||||
*/
|
||||
private List<AisTargetEntity> searchByFilterFromDb(AisTargetFilterRequest request) {
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
OffsetDateTime start = now.minusMinutes(request.getMinutes());
|
||||
|
||||
// DB에서는 시간 범위만 조회하고, 나머지 필터는 메모리에서 적용
|
||||
return aisTargetRepository.findByTimeRangeAndArea(
|
||||
start, now,
|
||||
0.0, 0.0, Double.MAX_VALUE
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 폴리곤 검색 ====================
|
||||
|
||||
/**
|
||||
* 폴리곤 범위 내 선박 검색 (캐시 기반)
|
||||
*
|
||||
* @param minutes 시간 범위 (분)
|
||||
* @param polygonCoordinates 폴리곤 좌표 [[lon, lat], ...]
|
||||
* @return 범위 내 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> searchByPolygon(int minutes, double[][] polygonCoordinates) {
|
||||
log.debug("폴리곤 검색 - minutes: {}, points: {}", minutes, polygonCoordinates.length);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
|
||||
|
||||
// 2. 폴리곤 필터링 (JTS 기반)
|
||||
entities = spatialFilterUtil.filterByPolygon(entities, polygonCoordinates);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("폴리곤 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
|
||||
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* WKT 형식 폴리곤으로 검색
|
||||
*
|
||||
* @param minutes 시간 범위 (분)
|
||||
* @param wkt WKT 문자열 (예: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
|
||||
* @return 범위 내 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> searchByWkt(int minutes, String wkt) {
|
||||
log.debug("WKT 검색 - minutes: {}, wkt: {}", minutes, wkt);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
|
||||
|
||||
// 2. WKT 필터링 (JTS 기반)
|
||||
entities = spatialFilterUtil.filterByWkt(entities, wkt);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("WKT 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
|
||||
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 거리 포함 검색 ====================
|
||||
|
||||
/**
|
||||
* 원형 범위 검색 + 거리 정보 포함
|
||||
*/
|
||||
public List<AisTargetWithDistanceDto> searchWithDistance(
|
||||
int minutes, double centerLon, double centerLat, double radiusMeters) {
|
||||
|
||||
log.debug("거리 포함 검색 - minutes: {}, center: ({}, {}), radius: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters);
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
|
||||
|
||||
// 2. 거리 포함 필터링
|
||||
List<SpatialFilterUtil.EntityWithDistance> filtered =
|
||||
spatialFilterUtil.filterByCircleWithDistance(entities, centerLon, centerLat, radiusMeters);
|
||||
|
||||
return filtered.stream()
|
||||
.map(ewd -> new AisTargetWithDistanceDto(
|
||||
AisTargetResponseDto.from(ewd.getEntity(), SOURCE_CACHE),
|
||||
ewd.getDistanceMeters()
|
||||
))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 항적 조회 ====================
|
||||
|
||||
/**
|
||||
* 특정 MMSI의 시간 범위 내 항적 조회
|
||||
*/
|
||||
public List<AisTargetResponseDto> getTrack(Long mmsi, Integer minutes) {
|
||||
log.debug("항적 조회 - MMSI: {}, 범위: {}분", mmsi, minutes);
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
OffsetDateTime start = now.minusMinutes(minutes);
|
||||
|
||||
List<AisTargetEntity> track = aisTargetRepository.findByMmsiAndTimeRange(mmsi, start, now);
|
||||
|
||||
log.debug("항적 조회 완료 - MMSI: {}, 포인트: {} 개", mmsi, track.size());
|
||||
|
||||
return track.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_DB))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 캐시 관리 ====================
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회
|
||||
*/
|
||||
public Map<String, Object> getCacheStats() {
|
||||
return cacheManager.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화
|
||||
*/
|
||||
public void clearCache() {
|
||||
cacheManager.clear();
|
||||
}
|
||||
|
||||
// ==================== 내부 DTO ====================
|
||||
|
||||
/**
|
||||
* 거리 정보 포함 응답 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.AllArgsConstructor
|
||||
public static class AisTargetWithDistanceDto {
|
||||
private AisTargetResponseDto target;
|
||||
private double distanceMeters;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package com.snp.batch.jobs.aistargetdbsync.batch.config;
|
||||
|
||||
import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.AisTargetDbSyncTasklet;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
import org.springframework.batch.core.JobExecutionListener;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* AIS Target DB Sync Job Config
|
||||
*
|
||||
* 스케줄: 매 15분 (0 0/15 * * * ?)
|
||||
* API: 없음 (캐시 기반)
|
||||
*
|
||||
* 동작:
|
||||
* - Caffeine 캐시에서 최근 15분 이내 데이터 조회
|
||||
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
|
||||
* - 1분 주기 aisTargetImportJob과 분리하여 DB 볼륨 최적화
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - aisTargetImportJob (1분): API → 캐시 업데이트
|
||||
* - aisTargetDbSyncJob (15분): 캐시 → DB 저장 (이 Job)
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class AisTargetDbSyncJobConfig {
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final AisTargetDbSyncTasklet aisTargetDbSyncTasklet;
|
||||
|
||||
public AisTargetDbSyncJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
AisTargetDbSyncTasklet aisTargetDbSyncTasklet) {
|
||||
this.jobRepository = jobRepository;
|
||||
this.transactionManager = transactionManager;
|
||||
this.aisTargetDbSyncTasklet = aisTargetDbSyncTasklet;
|
||||
}
|
||||
|
||||
@Bean(name = "aisTargetDbSyncStep")
|
||||
public Step aisTargetDbSyncStep() {
|
||||
return new StepBuilder("aisTargetDbSyncStep", jobRepository)
|
||||
.tasklet(aisTargetDbSyncTasklet, transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean(name = "aisTargetDbSyncJob")
|
||||
public Job aisTargetDbSyncJob() {
|
||||
log.info("Job 생성: aisTargetDbSyncJob");
|
||||
|
||||
return new JobBuilder("aisTargetDbSyncJob", jobRepository)
|
||||
.listener(new JobExecutionListener() {
|
||||
@Override
|
||||
public void beforeJob(JobExecution jobExecution) {
|
||||
log.info("[aisTargetDbSyncJob] DB Sync Job 시작");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterJob(JobExecution jobExecution) {
|
||||
long writeCount = jobExecution.getStepExecutions().stream()
|
||||
.mapToLong(se -> se.getWriteCount())
|
||||
.sum();
|
||||
|
||||
log.info("[aisTargetDbSyncJob] DB Sync Job 완료 - 상태: {}, 저장 건수: {}",
|
||||
jobExecution.getStatus(), writeCount);
|
||||
}
|
||||
})
|
||||
.start(aisTargetDbSyncStep())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AIS Target DB Sync Tasklet
|
||||
*
|
||||
* 스케줄: 매 15분 (0 0/15 * * * ?)
|
||||
*
|
||||
* 동작:
|
||||
* - Caffeine 캐시에서 최근 N분 이내 데이터 조회
|
||||
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
|
||||
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
|
||||
*
|
||||
* 참고:
|
||||
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
|
||||
* - DB 저장은 15분 주기로 수행하여 볼륨 절감
|
||||
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AisTargetDbSyncTasklet implements Tasklet {
|
||||
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
|
||||
/**
|
||||
* DB 동기화 시 조회할 캐시 데이터 시간 범위 (분)
|
||||
* 기본값: 15분 (스케줄 주기와 동일)
|
||||
*/
|
||||
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}")
|
||||
private int timeRangeMinutes;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
log.info("========================================");
|
||||
log.info("AIS Target DB Sync 시작");
|
||||
log.info("조회 범위: 최근 {}분", timeRangeMinutes);
|
||||
log.info("현재 캐시 크기: {}", cacheManager.size());
|
||||
log.info("========================================");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 최근 N분 이내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(timeRangeMinutes);
|
||||
|
||||
if (entities.isEmpty()) {
|
||||
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", timeRangeMinutes);
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
log.info("캐시에서 {} 건 조회 완료", entities.size());
|
||||
|
||||
// 2. DB에 UPSERT
|
||||
aisTargetRepository.batchUpsert(entities);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
|
||||
log.info("========================================");
|
||||
log.info("AIS Target DB Sync 완료");
|
||||
log.info("저장 건수: {} 건", entities.size());
|
||||
log.info("소요 시간: {}ms", elapsed);
|
||||
log.info("========================================");
|
||||
|
||||
// Step 통계 업데이트
|
||||
contribution.incrementWriteCount(entities.size());
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package com.snp.batch.jobs.common.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
|
||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
||||
import com.snp.batch.jobs.common.batch.processor.FlagCodeDataProcessor;
|
||||
import com.snp.batch.jobs.common.batch.reader.FlagCodeDataReader;
|
||||
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
|
||||
import com.snp.batch.jobs.common.batch.writer.FlagCodeDataWriter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class FlagCodeImportJobConfig extends BaseJobConfig<FlagCodeDto, FlagCodeEntity> {
|
||||
|
||||
private final FlagCodeRepository flagCodeRepository;
|
||||
private final WebClient maritimeApiWebClient;
|
||||
|
||||
@Value("${app.batch.chunk-size:1000}")
|
||||
private int chunkSize;
|
||||
|
||||
/**
|
||||
* 생성자 주입
|
||||
* maritimeApiWebClient: MaritimeApiWebClientConfig에서 등록한 Bean 주입
|
||||
*/
|
||||
public FlagCodeImportJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
FlagCodeRepository flagCodeRepository,
|
||||
@Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.flagCodeRepository = flagCodeRepository;
|
||||
this.maritimeApiWebClient = maritimeApiWebClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() {
|
||||
return "FlagCodeImportJob";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "FlagCodeImportStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<FlagCodeDto> createReader() {
|
||||
return new FlagCodeDataReader(maritimeApiWebClient);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<FlagCodeDto, FlagCodeEntity> createProcessor() {
|
||||
return new FlagCodeDataProcessor(flagCodeRepository);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<FlagCodeEntity> createWriter() {
|
||||
return new FlagCodeDataWriter(flagCodeRepository);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getChunkSize() {
|
||||
return chunkSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Job Bean 등록
|
||||
*/
|
||||
@Bean(name = "FlagCodeImportJob")
|
||||
public Job flagCodeImportJob() {
|
||||
return job();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step Bean 등록
|
||||
*/
|
||||
@Bean(name = "FlagCodeImportStep")
|
||||
public Step flagCodeImportStep() {
|
||||
return step();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.snp.batch.jobs.common.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
|
||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
||||
import com.snp.batch.jobs.common.batch.processor.Stat5CodeDataProcessor;
|
||||
import com.snp.batch.jobs.common.batch.reader.Stat5CodeDataReader;
|
||||
import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository;
|
||||
import com.snp.batch.jobs.common.batch.writer.Stat5CodeDataWriter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class Stat5CodeImportJobConfig extends BaseJobConfig<Stat5CodeDto, Stat5CodeEntity> {
|
||||
|
||||
private final Stat5CodeRepository stat5CodeRepository;
|
||||
private final WebClient maritimeAisApiWebClient;
|
||||
public Stat5CodeImportJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
Stat5CodeRepository stat5CodeRepository,
|
||||
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.stat5CodeRepository = stat5CodeRepository;
|
||||
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() { return "Stat5CodeImportJob"; }
|
||||
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "Stat5CodeImportStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<Stat5CodeDto> createReader() { return new Stat5CodeDataReader(maritimeAisApiWebClient); }
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<Stat5CodeDto, Stat5CodeEntity> createProcessor() { return new Stat5CodeDataProcessor(stat5CodeRepository); }
|
||||
|
||||
@Override
|
||||
protected ItemWriter<Stat5CodeEntity> createWriter() { return new Stat5CodeDataWriter(stat5CodeRepository); }
|
||||
|
||||
@Bean(name = "Stat5CodeImportJob")
|
||||
public Job stat5CodeImportJob() {
|
||||
return job();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step Bean 등록
|
||||
*/
|
||||
@Bean(name = "Stat5CodeImportStep")
|
||||
public Step stat5CodeImportStep() {
|
||||
return step();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.snp.batch.jobs.common.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class FlagCodeApiResponse {
|
||||
|
||||
@JsonProperty("associatedName")
|
||||
private String associatedName;
|
||||
|
||||
@JsonProperty("associatedCount")
|
||||
private Integer associatedCount;
|
||||
|
||||
@JsonProperty("APSAssociatedFlagISODetails")
|
||||
private List<FlagCodeDto> associatedFlagISODetails;
|
||||
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.snp.batch.jobs.common.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class FlagCodeDto {
|
||||
|
||||
@JsonProperty("DataSetVersion")
|
||||
private DataSetVersion dataSetVersion;
|
||||
|
||||
@JsonProperty("Code")
|
||||
private String code;
|
||||
|
||||
@JsonProperty("Decode")
|
||||
private String decode;
|
||||
|
||||
@JsonProperty("ISO2")
|
||||
private String iso2;
|
||||
|
||||
@JsonProperty("ISO3")
|
||||
private String iso3;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class DataSetVersion {
|
||||
@JsonProperty("DataSetVersion")
|
||||
private String version;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.snp.batch.jobs.common.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class Stat5CodeApiResponse {
|
||||
@JsonProperty("StatcodeArr")
|
||||
private List<Stat5CodeDto> statcodeArr;
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.snp.batch.jobs.common.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class Stat5CodeDto {
|
||||
@JsonProperty("Level1")
|
||||
private String level1;
|
||||
@JsonProperty("Level1Decode")
|
||||
private String Level1Decode;
|
||||
@JsonProperty("Level2")
|
||||
private String Level2;
|
||||
@JsonProperty("Level2Decode")
|
||||
private String Level2Decode;
|
||||
@JsonProperty("Level3")
|
||||
private String Level3;
|
||||
@JsonProperty("Level3Decode")
|
||||
private String Level3Decode;
|
||||
@JsonProperty("Level4")
|
||||
private String Level4;
|
||||
@JsonProperty("Level4Decode")
|
||||
private String Level4Decode;
|
||||
@JsonProperty("Level5")
|
||||
private String Level5;
|
||||
@JsonProperty("Level5Decode")
|
||||
private String Level5Decode;
|
||||
@JsonProperty("Description")
|
||||
private String Description;
|
||||
@JsonProperty("Release")
|
||||
private Integer Release;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.snp.batch.jobs.common.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class FlagCodeEntity extends BaseEntity {
|
||||
|
||||
private String dataSetVersion;
|
||||
|
||||
private String code;
|
||||
|
||||
private String decode;
|
||||
|
||||
private String iso2;
|
||||
|
||||
private String iso3;
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.snp.batch.jobs.common.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Stat5CodeEntity extends BaseEntity {
|
||||
|
||||
private String level1;
|
||||
|
||||
private String Level1Decode;
|
||||
|
||||
private String Level2;
|
||||
|
||||
private String Level2Decode;
|
||||
|
||||
private String Level3;
|
||||
|
||||
private String Level3Decode;
|
||||
|
||||
private String Level4;
|
||||
|
||||
private String Level4Decode;
|
||||
|
||||
private String Level5;
|
||||
|
||||
private String Level5Decode;
|
||||
|
||||
private String Description;
|
||||
|
||||
private String Release;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.snp.batch.jobs.common.batch.processor;
|
||||
|
||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
|
||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
||||
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class FlagCodeDataProcessor extends BaseProcessor<FlagCodeDto, FlagCodeEntity> {
|
||||
|
||||
private final FlagCodeRepository commonCodeRepository;
|
||||
|
||||
public FlagCodeDataProcessor(FlagCodeRepository commonCodeRepository) {
|
||||
this.commonCodeRepository = commonCodeRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FlagCodeEntity processItem(FlagCodeDto dto) throws Exception {
|
||||
|
||||
FlagCodeEntity entity = FlagCodeEntity.builder()
|
||||
.dataSetVersion(dto.getDataSetVersion().getVersion())
|
||||
.code(dto.getCode())
|
||||
.decode(dto.getDecode())
|
||||
.iso2(dto.getIso2())
|
||||
.iso3(dto.getIso3())
|
||||
.build();
|
||||
|
||||
log.debug("국가코드 데이터 처리 완료: FlagCode={}", dto.getCode());
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.snp.batch.jobs.common.batch.processor;
|
||||
|
||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
|
||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
||||
import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
|
||||
@Slf4j
|
||||
public class Stat5CodeDataProcessor extends BaseProcessor<Stat5CodeDto, Stat5CodeEntity> {
|
||||
private final Stat5CodeRepository stat5CodeRepository;
|
||||
|
||||
public Stat5CodeDataProcessor(Stat5CodeRepository stat5CodeRepository) {
|
||||
this.stat5CodeRepository = stat5CodeRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stat5CodeEntity processItem(Stat5CodeDto dto) throws Exception {
|
||||
Stat5CodeEntity entity = Stat5CodeEntity.builder()
|
||||
.level1(dto.getLevel1())
|
||||
.Level1Decode(dto.getLevel1Decode())
|
||||
.Level2(dto.getLevel2())
|
||||
.Level2Decode(dto.getLevel2Decode())
|
||||
.Level3(dto.getLevel3())
|
||||
.Level3Decode(dto.getLevel3Decode())
|
||||
.Level4(dto.getLevel4())
|
||||
.Level4Decode(dto.getLevel4Decode())
|
||||
.Level5(dto.getLevel5())
|
||||
.Level5Decode(dto.getLevel5Decode())
|
||||
.Description(dto.getDescription())
|
||||
.Release(Integer.toString(dto.getRelease()))
|
||||
.build();
|
||||
|
||||
log.debug("Stat5Code 데이터 처리 완료: Stat5Code={}", dto.getLevel5());
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package com.snp.batch.jobs.common.batch.reader;
|
||||
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeApiResponse;
|
||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class FlagCodeDataReader extends BaseApiReader<FlagCodeDto> {
|
||||
|
||||
public FlagCodeDataReader(WebClient webClient) {
|
||||
super(webClient); // BaseApiReader에 WebClient 전달
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필수 구현 메서드
|
||||
// ========================================
|
||||
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "FlagCodeDataReader";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<FlagCodeDto> fetchDataFromApi() {
|
||||
try {
|
||||
log.info("GetAssociatedFlagISOByName API 호출 시작");
|
||||
|
||||
FlagCodeApiResponse response = webClient
|
||||
.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/MaritimeWCF/APSShipService.svc/RESTFul/GetAssociatedFlagISOByName")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(FlagCodeApiResponse.class)
|
||||
.block();
|
||||
|
||||
if (response != null && response.getAssociatedFlagISODetails() != null) {
|
||||
log.info("API 응답 성공: 총 {} 건의 국가코드 데이터 수신", response.getAssociatedCount());
|
||||
return response.getAssociatedFlagISODetails();
|
||||
} else {
|
||||
log.warn("API 응답이 null이거나 국가코드 데이터가 없습니다");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("GetAssociatedFlagISOByName API 호출 실패", e);
|
||||
log.error("에러 메시지: {}", e.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package com.snp.batch.jobs.common.batch.reader;
|
||||
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeApiResponse;
|
||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class Stat5CodeDataReader extends BaseApiReader<Stat5CodeDto> {
|
||||
public Stat5CodeDataReader(WebClient webClient) {
|
||||
super(webClient); // BaseApiReader에 WebClient 전달
|
||||
}
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "Stat5CodeDataReader";
|
||||
}
|
||||
@Override
|
||||
protected List<Stat5CodeDto> fetchDataFromApi() {
|
||||
try {
|
||||
log.info("GetStatcodes API 호출 시작");
|
||||
|
||||
Stat5CodeApiResponse response = webClient
|
||||
.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/AisSvc.svc/AIS/GetStatcodes")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(Stat5CodeApiResponse.class)
|
||||
.block();
|
||||
|
||||
if (response != null && response.getStatcodeArr() != null) {
|
||||
log.info("API 응답 성공: 총 {} 건의 Stat5Code 데이터 수신", response.getStatcodeArr().size());
|
||||
return response.getStatcodeArr();
|
||||
} else {
|
||||
log.warn("API 응답이 null이거나 Stat5Code 데이터가 없습니다");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("GetAssociatedFlagISOByName API 호출 실패", e);
|
||||
log.error("에러 메시지: {}", e.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.snp.batch.jobs.common.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface FlagCodeRepository {
|
||||
|
||||
void saveAllFlagCode(List<FlagCodeEntity> items);
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package com.snp.batch.jobs.common.batch.repository;
|
||||
|
||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Repository("FlagCodeRepository")
|
||||
public class FlagCodeRepositoryImpl extends BaseJdbcRepository<FlagCodeEntity, String> implements FlagCodeRepository {
|
||||
|
||||
public FlagCodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getEntityName() {
|
||||
return "FlagCodeEntity";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return "snp_data.flagcode";
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected String getInsertSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getUpdateSql() {
|
||||
return """
|
||||
INSERT INTO snp_data.flagcode (
|
||||
datasetversion, code, decode, iso2, iso3
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (code)
|
||||
DO UPDATE SET
|
||||
datasetversion = EXCLUDED.datasetversion,
|
||||
decode = EXCLUDED.decode,
|
||||
iso2 = EXCLUDED.iso2,
|
||||
iso3 = EXCLUDED.iso3,
|
||||
batch_flag = 'N'
|
||||
""";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setInsertParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setUpdateParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception {
|
||||
int idx = 1;
|
||||
ps.setString(idx++, entity.getDataSetVersion());
|
||||
ps.setString(idx++, entity.getCode());
|
||||
ps.setString(idx++, entity.getDecode());
|
||||
ps.setString(idx++, entity.getIso2());
|
||||
ps.setString(idx++, entity.getIso3());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<FlagCodeEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String extractId(FlagCodeEntity entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAllFlagCode(List<FlagCodeEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setUpdateParameters(ps, entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 삽입 파라미터 설정 실패", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("{} 전체 저장 완료: {} 건", getEntityName(), items.size());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.snp.batch.jobs.common.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface Stat5CodeRepository {
|
||||
void saveAllStat5Code(List<Stat5CodeEntity> items);
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.snp.batch.jobs.common.batch.repository;
|
||||
|
||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Repository("Stat5CodeRepository")
|
||||
public class Stat5CodeRepositoryImpl extends BaseJdbcRepository<Stat5CodeEntity, String> implements Stat5CodeRepository{
|
||||
public Stat5CodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getEntityName() {
|
||||
return "Stat5CodeEntity";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return "snp_data.stat5code";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<Stat5CodeEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String extractId(Stat5CodeEntity entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getInsertSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getUpdateSql() {
|
||||
return """
|
||||
INSERT INTO snp_data.stat5code (
|
||||
level1, level1decode, level2, level2decode, level3, level3decode, level4, level4decode, level5, level5decode, description, release
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (level1, level2, level3, level4, level5)
|
||||
DO UPDATE SET
|
||||
level1 = EXCLUDED.level1,
|
||||
level1decode = EXCLUDED.level1decode,
|
||||
level2 = EXCLUDED.level2,
|
||||
level2decode = EXCLUDED.level2decode,
|
||||
level3 = EXCLUDED.level3,
|
||||
level3decode = EXCLUDED.level3decode,
|
||||
level4 = EXCLUDED.level4,
|
||||
level4decode = EXCLUDED.level4decode,
|
||||
level5 = EXCLUDED.level5,
|
||||
level5decode = EXCLUDED.level5decode,
|
||||
description = EXCLUDED.description,
|
||||
release = EXCLUDED.release,
|
||||
batch_flag = 'N'
|
||||
""";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setInsertParameters(PreparedStatement ps, Stat5CodeEntity entity) throws Exception {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setUpdateParameters(PreparedStatement ps, Stat5CodeEntity entity) throws Exception {
|
||||
int idx = 1;
|
||||
ps.setString(idx++, entity.getLevel1());
|
||||
ps.setString(idx++, entity.getLevel1Decode());
|
||||
ps.setString(idx++, entity.getLevel2());
|
||||
ps.setString(idx++, entity.getLevel2Decode());
|
||||
ps.setString(idx++, entity.getLevel3());
|
||||
ps.setString(idx++, entity.getLevel3Decode());
|
||||
ps.setString(idx++, entity.getLevel4());
|
||||
ps.setString(idx++, entity.getLevel4Decode());
|
||||
ps.setString(idx++, entity.getLevel5());
|
||||
ps.setString(idx++, entity.getLevel5Decode());
|
||||
ps.setString(idx++, entity.getDescription());
|
||||
ps.setString(idx++, entity.getRelease());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAllStat5Code(List<Stat5CodeEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setUpdateParameters(ps, entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 삽입 파라미터 설정 실패", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("{} 전체 저장 완료: {} 건", getEntityName(), items.size());
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.snp.batch.jobs.common.batch.writer;
|
||||
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
||||
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class FlagCodeDataWriter extends BaseWriter<FlagCodeEntity> {
|
||||
|
||||
private final FlagCodeRepository flagCodeRepository;
|
||||
|
||||
public FlagCodeDataWriter(FlagCodeRepository flagCodeRepository) {
|
||||
super("FlagCodeEntity");
|
||||
this.flagCodeRepository = flagCodeRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<FlagCodeEntity> items) throws Exception {
|
||||
flagCodeRepository.saveAllFlagCode(items);
|
||||
log.info("FlagCode 저장 완료: {} 건", items.size());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.snp.batch.jobs.common.batch.writer;
|
||||
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
||||
import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class Stat5CodeDataWriter extends BaseWriter<Stat5CodeEntity> {
|
||||
|
||||
private final Stat5CodeRepository stat5CodeRepository;
|
||||
|
||||
public Stat5CodeDataWriter(Stat5CodeRepository stat5CodeRepository) {
|
||||
super("Stat5CodeEntity");
|
||||
this.stat5CodeRepository = stat5CodeRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<Stat5CodeEntity> items) throws Exception {
|
||||
stat5CodeRepository.saveAllStat5Code(items);
|
||||
log.info("Stat5Code 저장 완료: {} 건", items.size());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,206 @@
|
||||
package com.snp.batch.jobs.compliance.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
|
||||
import com.snp.batch.jobs.compliance.batch.processor.CompanyComplianceDataProcessor;
|
||||
import com.snp.batch.jobs.compliance.batch.reader.CompanyComplianceDataRangeReader;
|
||||
import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataRangeReader;
|
||||
import com.snp.batch.jobs.compliance.batch.writer.CompanyComplianceDataWriter;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import com.snp.batch.service.BatchDateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class CompanyComplianceImportRangeJobConfig extends BaseMultiStepJobConfig<CompanyComplianceDto, CompanyComplianceEntity> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final WebClient maritimeServiceApiWebClient;
|
||||
private final CompanyComplianceDataRangeReader companyComplianceDataRangeReader;
|
||||
private final CompanyComplianceDataProcessor companyComplianceDataProcessor;
|
||||
private final CompanyComplianceDataWriter companyComplianceDataWriter;
|
||||
private final BatchDateService batchDateService;
|
||||
private final BatchApiLogService batchApiLogService;
|
||||
|
||||
@Value("${app.batch.webservice-api.url}")
|
||||
private String maritimeServiceApiUrl;
|
||||
protected String getApiKey() {return "COMPANY_COMPLIANCE_IMPORT_API";}
|
||||
protected String getBatchUpdateSql() {
|
||||
return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());}
|
||||
|
||||
@Override
|
||||
protected int getChunkSize() {
|
||||
return 5000;
|
||||
}
|
||||
public CompanyComplianceImportRangeJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
CompanyComplianceDataRangeReader companyComplianceDataRangeReader,
|
||||
CompanyComplianceDataProcessor companyComplianceDataProcessor,
|
||||
CompanyComplianceDataWriter companyComplianceDataWriter,
|
||||
JdbcTemplate jdbcTemplate,
|
||||
@Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient,
|
||||
BatchDateService batchDateService,
|
||||
BatchApiLogService batchApiLogService) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.companyComplianceDataRangeReader = companyComplianceDataRangeReader;
|
||||
this.maritimeServiceApiWebClient = maritimeServiceApiWebClient;
|
||||
this.companyComplianceDataProcessor = companyComplianceDataProcessor;
|
||||
this.companyComplianceDataWriter = companyComplianceDataWriter;
|
||||
this.batchDateService = batchDateService;
|
||||
this.batchApiLogService = batchApiLogService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() {
|
||||
return "CompanyComplianceImportRangeJob";
|
||||
}
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "CompanyComplianceImportRangeStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Job createJobFlow(JobBuilder jobBuilder) {
|
||||
return jobBuilder
|
||||
.start(companyComplianceImportRangeStep()) // 1단계 실행
|
||||
.next(companyComplianceHistoryValueChangeManageStep()) // 2단계 실행 (2단계 실패 시 실행 안 됨)
|
||||
.next(companyComplianceLastExecutionUpdateStep()) // 3단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<CompanyComplianceDto> createReader() {
|
||||
return companyComplianceDataRangeReader;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@StepScope
|
||||
public CompanyComplianceDataRangeReader companyComplianceDataRangeReader(
|
||||
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출
|
||||
@Value("#{stepExecution.id}") Long stepExecutionId
|
||||
) {
|
||||
CompanyComplianceDataRangeReader reader = new CompanyComplianceDataRangeReader(maritimeServiceApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl);
|
||||
reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅
|
||||
return reader;
|
||||
}
|
||||
@Override
|
||||
protected ItemProcessor<CompanyComplianceDto, CompanyComplianceEntity> createProcessor() {
|
||||
return companyComplianceDataProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<CompanyComplianceEntity> createWriter() {
|
||||
return companyComplianceDataWriter;
|
||||
}
|
||||
|
||||
@Bean(name = "CompanyComplianceImportRangeJob")
|
||||
public Job companyComplianceImportRangeJob() {
|
||||
return job();
|
||||
}
|
||||
|
||||
@Bean(name = "CompanyComplianceImportRangeStep")
|
||||
public Step companyComplianceImportRangeStep() {
|
||||
return step();
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계: Compliance History Value Change 관리
|
||||
*/
|
||||
@Bean
|
||||
public Tasklet companyComplianceHistoryValueChangeManageTasklet() {
|
||||
return (contribution, chunkContext) -> {
|
||||
log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 시작");
|
||||
|
||||
// 1. 입력 포맷(UTC 'Z' 포함) 및 프로시저용 타겟 포맷 정의
|
||||
DateTimeFormatter inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX");
|
||||
DateTimeFormatter targetFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
|
||||
|
||||
String rawFromDate = params.get("fromDate");
|
||||
String rawToDate = params.get("toDate");
|
||||
|
||||
// 2. UTC 문자열 -> OffsetDateTime -> Asia/Seoul 변환 -> LocalDateTime 추출
|
||||
String startDt = convertToKstString(rawFromDate, inputFormatter, targetFormatter);
|
||||
String endDt = convertToKstString(rawToDate, inputFormatter, targetFormatter);
|
||||
|
||||
log.info("Company Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt);
|
||||
|
||||
// 3. 프로시저 호출 (안전한 파라미터 바인딩 권장)
|
||||
jdbcTemplate.update("CALL new_snp.company_compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", startDt, endDt);
|
||||
|
||||
log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 완료");
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* UTC 문자열을 한국 시간(KST) 문자열로 변환하는 헬퍼 메소드
|
||||
*/
|
||||
private String convertToKstString(String rawDate, DateTimeFormatter input, DateTimeFormatter target) {
|
||||
if (rawDate == null) return null;
|
||||
|
||||
// 1. 문자열을 OffsetDateTime으로 파싱 (Z를 인식하여 UTC 시간으로 인지함)
|
||||
return OffsetDateTime.parse(rawDate, input)
|
||||
// 2. 시간대를 서울(+09:00)로 변경 (값이 9시간 더해짐)
|
||||
.atZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||
// 3. 프로시저 형식에 맞게 포맷팅
|
||||
.format(target);
|
||||
}
|
||||
@Bean(name = "CompanyComplianceHistoryValueChangeManageStep")
|
||||
public Step companyComplianceHistoryValueChangeManageStep() {
|
||||
return new StepBuilder("CompanyComplianceHistoryValueChangeManageStep", jobRepository)
|
||||
.tasklet(companyComplianceHistoryValueChangeManageTasklet(), transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 3단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트
|
||||
*/
|
||||
@Bean
|
||||
public Tasklet companyComplianceLastExecutionUpdateTasklet() {
|
||||
return (contribution, chunkContext) -> {
|
||||
log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작");
|
||||
|
||||
jdbcTemplate.execute(getBatchUpdateSql());
|
||||
|
||||
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료");
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
@Bean(name = "CompanyComplianceLastExecutionUpdateStep")
|
||||
public Step companyComplianceLastExecutionUpdateStep() {
|
||||
return new StepBuilder("CompanyComplianceLastExecutionUpdateStep", jobRepository)
|
||||
.tasklet(companyComplianceLastExecutionUpdateTasklet(), transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
package com.snp.batch.jobs.compliance.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
|
||||
import com.snp.batch.jobs.compliance.batch.processor.ComplianceDataProcessor;
|
||||
import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataReader;
|
||||
import com.snp.batch.jobs.compliance.batch.writer.ComplianceDataWriter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class ComplianceImportJobConfig extends BaseJobConfig<ComplianceDto, ComplianceEntity> {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final WebClient maritimeServiceApiWebClient;
|
||||
|
||||
private final ComplianceDataProcessor complianceDataProcessor;
|
||||
|
||||
private final ComplianceDataWriter complianceDataWriter;
|
||||
|
||||
@Override
|
||||
protected int getChunkSize() {
|
||||
return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정
|
||||
}
|
||||
public ComplianceImportJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
ComplianceDataProcessor complianceDataProcessor,
|
||||
ComplianceDataWriter complianceDataWriter,
|
||||
JdbcTemplate jdbcTemplate,
|
||||
@Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.maritimeServiceApiWebClient = maritimeServiceApiWebClient;
|
||||
this.complianceDataProcessor = complianceDataProcessor;
|
||||
this.complianceDataWriter = complianceDataWriter;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() {
|
||||
return "ComplianceImportJob";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "ComplianceImportStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<ComplianceDto> createReader() {
|
||||
return new ComplianceDataReader(maritimeServiceApiWebClient, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<ComplianceDto, ComplianceEntity> createProcessor() {
|
||||
return complianceDataProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<ComplianceEntity> createWriter() {
|
||||
return complianceDataWriter;
|
||||
}
|
||||
|
||||
@Bean(name = "ComplianceImportJob")
|
||||
public Job complianceImportJob() {
|
||||
return job();
|
||||
}
|
||||
|
||||
@Bean(name = "ComplianceImportStep")
|
||||
public Step complianceImportStep() {
|
||||
return step();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
package com.snp.batch.jobs.compliance.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
|
||||
import com.snp.batch.jobs.compliance.batch.processor.ComplianceDataProcessor;
|
||||
import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataRangeReader;
|
||||
import com.snp.batch.jobs.compliance.batch.writer.ComplianceDataWriter;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import com.snp.batch.service.BatchDateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class ComplianceImportRangeJobConfig extends BaseMultiStepJobConfig<ComplianceDto, ComplianceEntity> {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final WebClient maritimeServiceApiWebClient;
|
||||
private final ComplianceDataProcessor complianceDataProcessor;
|
||||
private final ComplianceDataWriter complianceDataWriter;
|
||||
private final ComplianceDataRangeReader complianceDataRangeReader;
|
||||
private final BatchDateService batchDateService;
|
||||
private final BatchApiLogService batchApiLogService;
|
||||
|
||||
@Value("${app.batch.webservice-api.url}")
|
||||
private String maritimeServiceApiUrl;
|
||||
protected String getApiKey() {return "COMPLIANCE_IMPORT_API";}
|
||||
protected String getBatchUpdateSql() {
|
||||
return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());}
|
||||
|
||||
@Override
|
||||
protected int getChunkSize() {
|
||||
return 5000;
|
||||
}
|
||||
public ComplianceImportRangeJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
ComplianceDataProcessor complianceDataProcessor,
|
||||
ComplianceDataWriter complianceDataWriter,
|
||||
JdbcTemplate jdbcTemplate,
|
||||
@Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient,
|
||||
ComplianceDataRangeReader complianceDataRangeReader,
|
||||
BatchDateService batchDateService,
|
||||
BatchApiLogService batchApiLogService) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.maritimeServiceApiWebClient = maritimeServiceApiWebClient;
|
||||
this.complianceDataProcessor = complianceDataProcessor;
|
||||
this.complianceDataWriter = complianceDataWriter;
|
||||
this.complianceDataRangeReader = complianceDataRangeReader;
|
||||
this.batchDateService = batchDateService;
|
||||
this.batchApiLogService = batchApiLogService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() {
|
||||
return "ComplianceImportRangeJob";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "ComplianceImportRangeStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Job createJobFlow(JobBuilder jobBuilder) {
|
||||
return jobBuilder
|
||||
.start(complianceImportRangeStep()) // 1단계 실행
|
||||
.next(complianceHistoryValueChangeManageStep()) // 2단계 실행 (2단계 실패 시 실행 안 됨)
|
||||
.next(complianceLastExecutionUpdateStep()) // 3단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<ComplianceDto> createReader() {
|
||||
return complianceDataRangeReader;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@StepScope
|
||||
public ComplianceDataRangeReader complianceDataRangeReader(
|
||||
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출
|
||||
@Value("#{stepExecution.id}") Long stepExecutionId
|
||||
) {
|
||||
ComplianceDataRangeReader reader = new ComplianceDataRangeReader(maritimeServiceApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeServiceApiUrl);
|
||||
reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅
|
||||
return reader;
|
||||
}
|
||||
@Override
|
||||
protected ItemProcessor<ComplianceDto, ComplianceEntity> createProcessor() {
|
||||
return complianceDataProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<ComplianceEntity> createWriter() {
|
||||
return complianceDataWriter;
|
||||
}
|
||||
|
||||
@Bean(name = "ComplianceImportRangeJob")
|
||||
public Job complianceImportRangeJob() {
|
||||
return job();
|
||||
}
|
||||
|
||||
@Bean(name = "ComplianceImportRangeStep")
|
||||
public Step complianceImportRangeStep() {
|
||||
return step();
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계: Compliance History Value Change 관리
|
||||
*/
|
||||
@Bean
|
||||
public Tasklet complianceHistoryValueChangeManageTasklet() {
|
||||
return (contribution, chunkContext) -> {
|
||||
log.info(">>>>> Compliance History Value Change Manage 프로시저 호출 시작");
|
||||
|
||||
// 1. 입력 포맷(UTC 'Z' 포함) 및 프로시저용 타겟 포맷 정의
|
||||
DateTimeFormatter inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX");
|
||||
DateTimeFormatter targetFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
|
||||
|
||||
String rawFromDate = params.get("fromDate");
|
||||
String rawToDate = params.get("toDate");
|
||||
|
||||
// 2. UTC 문자열 -> OffsetDateTime -> Asia/Seoul 변환 -> LocalDateTime 추출
|
||||
String startDt = convertToKstString(rawFromDate, inputFormatter, targetFormatter);
|
||||
String endDt = convertToKstString(rawToDate, inputFormatter, targetFormatter);
|
||||
|
||||
log.info("Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt);
|
||||
|
||||
// 3. 프로시저 호출 (안전한 파라미터 바인딩 권장)
|
||||
jdbcTemplate.update("CALL new_snp.compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", startDt, endDt);
|
||||
|
||||
log.info(">>>>> Compliance History Value Change Manage 프로시저 호출 완료");
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* UTC 문자열을 한국 시간(KST) 문자열로 변환하는 헬퍼 메소드
|
||||
*/
|
||||
private String convertToKstString(String rawDate, DateTimeFormatter input, DateTimeFormatter target) {
|
||||
if (rawDate == null) return null;
|
||||
|
||||
// 1. 문자열을 OffsetDateTime으로 파싱 (Z를 인식하여 UTC 시간으로 인지함)
|
||||
return OffsetDateTime.parse(rawDate, input)
|
||||
// 2. 시간대를 서울(+09:00)로 변경 (값이 9시간 더해짐)
|
||||
.atZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||
// 3. 프로시저 형식에 맞게 포맷팅
|
||||
.format(target);
|
||||
}
|
||||
@Bean(name = "ComplianceHistoryValueChangeManageStep")
|
||||
public Step complianceHistoryValueChangeManageStep() {
|
||||
return new StepBuilder("ComplianceHistoryValueChangeManageStep", jobRepository)
|
||||
.tasklet(complianceHistoryValueChangeManageTasklet(), transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 3단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트
|
||||
*/
|
||||
@Bean
|
||||
public Tasklet complianceLastExecutionUpdateTasklet() {
|
||||
return (contribution, chunkContext) -> {
|
||||
log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작");
|
||||
|
||||
jdbcTemplate.execute(getBatchUpdateSql());
|
||||
|
||||
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료");
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
@Bean(name = "ComplianceLastExecutionUpdateStep")
|
||||
public Step complianceLastExecutionUpdateStep() {
|
||||
return new StepBuilder("ComplianceLastExecutionUpdateStep", jobRepository)
|
||||
.tasklet(complianceLastExecutionUpdateTasklet(), transactionManager)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package com.snp.batch.jobs.compliance.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CompanyComplianceDto {
|
||||
@JsonProperty("owcode")
|
||||
private String owcode;
|
||||
|
||||
@JsonProperty("lastUpdated")
|
||||
private String lastUpdated;
|
||||
|
||||
@JsonProperty("companyOverallComplianceStatus")
|
||||
private Integer companyOverallComplianceStatus;
|
||||
|
||||
@JsonProperty("companyOnAustralianSanctionList")
|
||||
private Integer companyOnAustralianSanctionList;
|
||||
|
||||
@JsonProperty("companyOnBESSanctionList")
|
||||
private Integer companyOnBESSanctionList;
|
||||
|
||||
@JsonProperty("companyOnCanadianSanctionList")
|
||||
private Integer companyOnCanadianSanctionList;
|
||||
|
||||
@JsonProperty("companyInOFACSanctionedCountry")
|
||||
private Integer companyInOFACSanctionedCountry;
|
||||
|
||||
@JsonProperty("companyInFATFJurisdiction")
|
||||
private Integer companyInFATFJurisdiction;
|
||||
|
||||
@JsonProperty("companyOnEUSanctionList")
|
||||
private Integer companyOnEUSanctionList;
|
||||
|
||||
@JsonProperty("companyOnOFACSanctionList")
|
||||
private Integer companyOnOFACSanctionList;
|
||||
|
||||
@JsonProperty("companyOnOFACNONSDNSanctionList")
|
||||
private Integer companyOnOFACNONSDNSanctionList;
|
||||
|
||||
@JsonProperty("companyOnOFACSSISanctionList")
|
||||
private Integer companyOnOFACSSISanctionList;
|
||||
|
||||
@JsonProperty("parentCompanyNonCompliance")
|
||||
private Integer parentCompanyNonCompliance;
|
||||
|
||||
@JsonProperty("companyOnSwissSanctionList")
|
||||
private Integer companyOnSwissSanctionList;
|
||||
|
||||
@JsonProperty("companyOnUAESanctionList")
|
||||
private Integer companyOnUAESanctionList;
|
||||
|
||||
@JsonProperty("companyOnUNSanctionList")
|
||||
private Integer companyOnUNSanctionList;
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package com.snp.batch.jobs.compliance.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ComplianceDto {
|
||||
|
||||
@JsonProperty("shipEUSanctionList")
|
||||
private Integer shipEUSanctionList;
|
||||
|
||||
@JsonProperty("shipUNSanctionList")
|
||||
private Integer shipUNSanctionList;
|
||||
|
||||
@JsonProperty("lrimoShipNo")
|
||||
private String lrimoShipNo;
|
||||
|
||||
@JsonProperty("dateAmended")
|
||||
private String dateAmended; // 수정일시
|
||||
|
||||
// 2. Compliance Status (Integer 타입은 0, 1, 2 등의 코드 및 null 값 처리)
|
||||
@JsonProperty("legalOverall")
|
||||
private Integer legalOverall; // 종합제재
|
||||
|
||||
@JsonProperty("shipBESSanctionList")
|
||||
private Integer shipBESSanctionList; // 선박BES제재
|
||||
|
||||
@JsonProperty("shipDarkActivityIndicator")
|
||||
private Integer shipDarkActivityIndicator; // 선박다크활동
|
||||
|
||||
@JsonProperty("shipDetailsNoLongerMaintained")
|
||||
private Integer shipDetailsNoLongerMaintained; // 선박세부정보미유지
|
||||
|
||||
@JsonProperty("shipFlagDisputed")
|
||||
private Integer shipFlagDisputed; // 선박국기논쟁
|
||||
|
||||
@JsonProperty("shipFlagSanctionedCountry")
|
||||
private Integer shipFlagSanctionedCountry; // 선박국가제재
|
||||
|
||||
@JsonProperty("shipHistoricalFlagSanctionedCountry")
|
||||
private Integer shipHistoricalFlagSanctionedCountry; // 선박국가제재이력
|
||||
|
||||
@JsonProperty("shipOFACNonSDNSanctionList")
|
||||
private Integer shipOFACNonSDNSanctionList; // 선박OFAC비SDN제재
|
||||
|
||||
@JsonProperty("shipOFACSanctionList")
|
||||
private Integer shipOFACSanctionList; // 선박OFAC제재
|
||||
|
||||
@JsonProperty("shipOFACAdvisoryList")
|
||||
private Integer shipOFACAdvisoryList; // 선박OFAC주의
|
||||
|
||||
@JsonProperty("shipOwnerOFACSSIList")
|
||||
private Integer shipOwnerOFACSSIList; // 선박소유자OFCS제재
|
||||
|
||||
@JsonProperty("shipOwnerAustralianSanctionList")
|
||||
private Integer shipOwnerAustralianSanctionList; // 선박소유자AUS제재
|
||||
|
||||
@JsonProperty("shipOwnerBESSanctionList")
|
||||
private Integer shipOwnerBESSanctionList; // 선박소유자BES제재
|
||||
|
||||
@JsonProperty("shipOwnerCanadianSanctionList")
|
||||
private Integer shipOwnerCanadianSanctionList; // 선박소유자CAN제재
|
||||
|
||||
@JsonProperty("shipOwnerEUSanctionList")
|
||||
private Integer shipOwnerEUSanctionList; // 선박소유자EU제재
|
||||
|
||||
@JsonProperty("shipOwnerFATFJurisdiction")
|
||||
private Integer shipOwnerFATFJurisdiction; // 선박소유자FATF규제구역
|
||||
|
||||
@JsonProperty("shipOwnerHistoricalOFACSanctionedCountry")
|
||||
private Integer shipOwnerHistoricalOFACSanctionedCountry; // 선박소유자OFAC제재이력
|
||||
|
||||
@JsonProperty("shipOwnerOFACSanctionList")
|
||||
private Integer shipOwnerOFACSanctionList; // 선박소유자OFAC제재
|
||||
|
||||
@JsonProperty("shipOwnerOFACSanctionedCountry")
|
||||
private Integer shipOwnerOFACSanctionedCountry; // 선박소유자OFAC제재국가
|
||||
|
||||
@JsonProperty("shipOwnerParentCompanyNonCompliance")
|
||||
private Integer shipOwnerParentCompanyNonCompliance; // 선박소유자모회사비준수
|
||||
|
||||
@JsonProperty("shipOwnerParentFATFJurisdiction")
|
||||
private Integer shipOwnerParentFATFJurisdiction; // 선박소유자모회사FATF규제구역 (JSON에 null 포함)
|
||||
|
||||
@JsonProperty("shipOwnerParentOFACSanctionedCountry")
|
||||
private Integer shipOwnerParentOFACSanctionedCountry; // 선박소유자모회사OFAC제재국가 (JSON에 null 포함)
|
||||
|
||||
@JsonProperty("shipOwnerSwissSanctionList")
|
||||
private Integer shipOwnerSwissSanctionList; // 선박소유자SWI제재
|
||||
|
||||
@JsonProperty("shipOwnerUAESanctionList")
|
||||
private Integer shipOwnerUAESanctionList; // 선박소유자UAE제재
|
||||
|
||||
@JsonProperty("shipOwnerUNSanctionList")
|
||||
private Integer shipOwnerUNSanctionList; // 선박소유자UN제재
|
||||
|
||||
@JsonProperty("shipSanctionedCountryPortCallLast12m")
|
||||
private Integer shipSanctionedCountryPortCallLast12m; // 선박제재국가기항최종12M
|
||||
|
||||
@JsonProperty("shipSanctionedCountryPortCallLast3m")
|
||||
private Integer shipSanctionedCountryPortCallLast3m; // 선박제재국가기항최종3M
|
||||
|
||||
@JsonProperty("shipSanctionedCountryPortCallLast6m")
|
||||
private Integer shipSanctionedCountryPortCallLast6m; // 선박제재국가기항최종6M
|
||||
|
||||
@JsonProperty("shipSecurityLegalDisputeEvent")
|
||||
private Integer shipSecurityLegalDisputeEvent; // 선박보안법적분쟁이벤트
|
||||
|
||||
@JsonProperty("shipSTSPartnerNonComplianceLast12m")
|
||||
private Integer shipSTSPartnerNonComplianceLast12m; // 선박STS파트너비준수12M
|
||||
|
||||
@JsonProperty("shipSwissSanctionList")
|
||||
private Integer shipSwissSanctionList; // 선박SWI제재
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.snp.batch.jobs.compliance.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class CompanyComplianceEntity extends BaseEntity {
|
||||
private String owcode;
|
||||
|
||||
private String lastUpdated;
|
||||
|
||||
private Integer companyOverallComplianceStatus;
|
||||
|
||||
private Integer companyOnAustralianSanctionList;
|
||||
|
||||
private Integer companyOnBESSanctionList;
|
||||
|
||||
private Integer companyOnCanadianSanctionList;
|
||||
|
||||
private Integer companyInOFACSanctionedCountry;
|
||||
|
||||
private Integer companyInFATFJurisdiction;
|
||||
|
||||
private Integer companyOnEUSanctionList;
|
||||
|
||||
private Integer companyOnOFACSanctionList;
|
||||
|
||||
private Integer companyOnOFACNONSDNSanctionList;
|
||||
|
||||
private Integer companyOnOFACSSISanctionList;
|
||||
|
||||
private Integer parentCompanyNonCompliance;
|
||||
|
||||
private Integer companyOnSwissSanctionList;
|
||||
|
||||
private Integer companyOnUAESanctionList;
|
||||
|
||||
private Integer companyOnUNSanctionList;
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package com.snp.batch.jobs.compliance.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ComplianceEntity extends BaseEntity {
|
||||
|
||||
private String lrimoShipNo; // LR/IMO번호
|
||||
|
||||
private String dateAmended; // 수정일시
|
||||
|
||||
// 2. Compliance Status (모든 필드는 DTO와 동일한 Integer 타입)
|
||||
private Integer legalOverall; // 종합제재
|
||||
|
||||
private Integer shipBESSanctionList; // 선박BES제재
|
||||
|
||||
private Integer shipDarkActivityIndicator; // 선박다크활동
|
||||
|
||||
private Integer shipDetailsNoLongerMaintained; // 선박세부정보미유지
|
||||
|
||||
private Integer shipEUSanctionList; // 선박EU제재
|
||||
|
||||
private Integer shipFlagDisputed; // 선박국기논쟁
|
||||
|
||||
private Integer shipFlagSanctionedCountry; // 선박국가제재
|
||||
|
||||
private Integer shipHistoricalFlagSanctionedCountry; // 선박국가제재이력
|
||||
|
||||
private Integer shipOFACNonSDNSanctionList; // 선박OFAC비SDN제재
|
||||
|
||||
private Integer shipOFACSanctionList; // 선박OFAC제재
|
||||
|
||||
private Integer shipOFACAdvisoryList; // 선박OFAC주의
|
||||
|
||||
private Integer shipOwnerOFACSSIList; // 선박소유자OFCS제재
|
||||
|
||||
private Integer shipOwnerAustralianSanctionList; // 선박소유자AUS제재
|
||||
|
||||
private Integer shipOwnerBESSanctionList; // 선박소유자BES제재
|
||||
|
||||
private Integer shipOwnerCanadianSanctionList; // 선박소유자CAN제재
|
||||
|
||||
private Integer shipOwnerEUSanctionList; // 선박소유자EU제재
|
||||
|
||||
private Integer shipOwnerFATFJurisdiction; // 선박소유자FATF규제구역
|
||||
|
||||
private Integer shipOwnerHistoricalOFACSanctionedCountry; // 선박소유자OFAC제재이력
|
||||
|
||||
private Integer shipOwnerOFACSanctionList; // 선박소유자OFAC제재
|
||||
|
||||
private Integer shipOwnerOFACSanctionedCountry; // 선박소유자OFAC제재국가
|
||||
|
||||
private Integer shipOwnerParentCompanyNonCompliance; // 선박소유자모회사비준수
|
||||
|
||||
private Integer shipOwnerParentFATFJurisdiction; // 선박소유자모회사FATF규제구역
|
||||
|
||||
private Integer shipOwnerParentOFACSanctionedCountry; // 선박소유자모회사OFAC제재국가
|
||||
|
||||
private Integer shipOwnerSwissSanctionList; // 선박소유자SWI제재
|
||||
|
||||
private Integer shipOwnerUAESanctionList; // 선박소유자UAE제재
|
||||
|
||||
private Integer shipOwnerUNSanctionList; // 선박소유자UN제재
|
||||
|
||||
private Integer shipSanctionedCountryPortCallLast12m; // 선박제재국가기항최종12M
|
||||
|
||||
private Integer shipSanctionedCountryPortCallLast3m; // 선박제재국가기항최종3M
|
||||
|
||||
private Integer shipSanctionedCountryPortCallLast6m; // 선박제재국가기항최종6M
|
||||
|
||||
private Integer shipSecurityLegalDisputeEvent; // 선박보안법적분쟁이벤트
|
||||
|
||||
private Integer shipSTSPartnerNonComplianceLast12m; // 선박STS파트너비준수12M
|
||||
|
||||
private Integer shipSwissSanctionList; // 선박SWI제재
|
||||
|
||||
private Integer shipUNSanctionList; // 선박UN제재
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.snp.batch.jobs.compliance.batch.processor;
|
||||
|
||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CompanyComplianceDataProcessor extends BaseProcessor<CompanyComplianceDto, CompanyComplianceEntity> {
|
||||
|
||||
@Override
|
||||
protected CompanyComplianceEntity processItem(CompanyComplianceDto dto) throws Exception {
|
||||
|
||||
CompanyComplianceEntity entity = CompanyComplianceEntity.builder()
|
||||
.owcode(dto.getOwcode())
|
||||
.lastUpdated(dto.getLastUpdated())
|
||||
.companyOverallComplianceStatus(dto.getCompanyOverallComplianceStatus())
|
||||
.companyOnAustralianSanctionList(dto.getCompanyOnAustralianSanctionList())
|
||||
.companyOnBESSanctionList(dto.getCompanyOnBESSanctionList())
|
||||
.companyOnCanadianSanctionList(dto.getCompanyOnCanadianSanctionList())
|
||||
.companyInOFACSanctionedCountry(dto.getCompanyInOFACSanctionedCountry())
|
||||
.companyInFATFJurisdiction(dto.getCompanyInFATFJurisdiction())
|
||||
.companyOnEUSanctionList(dto.getCompanyOnEUSanctionList())
|
||||
.companyOnOFACSanctionList(dto.getCompanyOnOFACSanctionList())
|
||||
.companyOnOFACNONSDNSanctionList(dto.getCompanyOnOFACNONSDNSanctionList())
|
||||
.companyOnOFACSSISanctionList(dto.getCompanyOnOFACSSISanctionList())
|
||||
.parentCompanyNonCompliance(dto.getParentCompanyNonCompliance())
|
||||
.companyOnSwissSanctionList(dto.getCompanyOnSwissSanctionList())
|
||||
.companyOnUAESanctionList(dto.getCompanyOnUAESanctionList())
|
||||
.companyOnUNSanctionList(dto.getCompanyOnUNSanctionList())
|
||||
.build();
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package com.snp.batch.jobs.compliance.batch.processor;
|
||||
|
||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ComplianceDataProcessor extends BaseProcessor<ComplianceDto, ComplianceEntity> {
|
||||
@Override
|
||||
protected ComplianceEntity processItem(ComplianceDto dto) throws Exception {
|
||||
|
||||
ComplianceEntity entity = ComplianceEntity.builder()
|
||||
// 1. Primary Keys
|
||||
.lrimoShipNo(dto.getLrimoShipNo())
|
||||
.dateAmended(dto.getDateAmended())
|
||||
// 2. Compliance Status
|
||||
.legalOverall(dto.getLegalOverall())
|
||||
.shipBESSanctionList(dto.getShipBESSanctionList())
|
||||
.shipDarkActivityIndicator(dto.getShipDarkActivityIndicator())
|
||||
.shipDetailsNoLongerMaintained(dto.getShipDetailsNoLongerMaintained())
|
||||
.shipEUSanctionList(dto.getShipEUSanctionList())
|
||||
.shipFlagDisputed(dto.getShipFlagDisputed())
|
||||
.shipFlagSanctionedCountry(dto.getShipFlagSanctionedCountry())
|
||||
.shipHistoricalFlagSanctionedCountry(dto.getShipHistoricalFlagSanctionedCountry())
|
||||
.shipOFACNonSDNSanctionList(dto.getShipOFACNonSDNSanctionList())
|
||||
.shipOFACSanctionList(dto.getShipOFACSanctionList())
|
||||
.shipOFACAdvisoryList(dto.getShipOFACAdvisoryList())
|
||||
.shipOwnerOFACSSIList(dto.getShipOwnerOFACSSIList())
|
||||
.shipOwnerAustralianSanctionList(dto.getShipOwnerAustralianSanctionList())
|
||||
.shipOwnerBESSanctionList(dto.getShipOwnerBESSanctionList())
|
||||
.shipOwnerCanadianSanctionList(dto.getShipOwnerCanadianSanctionList())
|
||||
.shipOwnerEUSanctionList(dto.getShipOwnerEUSanctionList())
|
||||
.shipOwnerFATFJurisdiction(dto.getShipOwnerFATFJurisdiction())
|
||||
.shipOwnerHistoricalOFACSanctionedCountry(dto.getShipOwnerHistoricalOFACSanctionedCountry())
|
||||
.shipOwnerOFACSanctionList(dto.getShipOwnerOFACSanctionList())
|
||||
.shipOwnerOFACSanctionedCountry(dto.getShipOwnerOFACSanctionedCountry())
|
||||
.shipOwnerParentCompanyNonCompliance(dto.getShipOwnerParentCompanyNonCompliance())
|
||||
.shipOwnerParentFATFJurisdiction(dto.getShipOwnerParentFATFJurisdiction())
|
||||
.shipOwnerParentOFACSanctionedCountry(dto.getShipOwnerParentOFACSanctionedCountry())
|
||||
.shipOwnerSwissSanctionList(dto.getShipOwnerSwissSanctionList())
|
||||
.shipOwnerUAESanctionList(dto.getShipOwnerUAESanctionList())
|
||||
.shipOwnerUNSanctionList(dto.getShipOwnerUNSanctionList())
|
||||
.shipSanctionedCountryPortCallLast12m(dto.getShipSanctionedCountryPortCallLast12m())
|
||||
.shipSanctionedCountryPortCallLast3m(dto.getShipSanctionedCountryPortCallLast3m())
|
||||
.shipSanctionedCountryPortCallLast6m(dto.getShipSanctionedCountryPortCallLast6m())
|
||||
.shipSecurityLegalDisputeEvent(dto.getShipSecurityLegalDisputeEvent())
|
||||
.shipSTSPartnerNonComplianceLast12m(dto.getShipSTSPartnerNonComplianceLast12m())
|
||||
.shipSwissSanctionList(dto.getShipSwissSanctionList())
|
||||
.shipUNSanctionList(dto.getShipUNSanctionList())
|
||||
.build();
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
package com.snp.batch.jobs.compliance.batch.reader;
|
||||
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import com.snp.batch.service.BatchDateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class CompanyComplianceDataRangeReader extends BaseApiReader<CompanyComplianceDto> {
|
||||
private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가
|
||||
private final BatchApiLogService batchApiLogService;
|
||||
String maritimeServiceApiUrl;
|
||||
private List<CompanyComplianceDto> allData;
|
||||
private int currentBatchIndex = 0;
|
||||
private final int batchSize = 5000;
|
||||
|
||||
public CompanyComplianceDataRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) {
|
||||
super(webClient);
|
||||
this.batchDateService = batchDateService;
|
||||
this.batchApiLogService = batchApiLogService;
|
||||
this.maritimeServiceApiUrl = maritimeServiceApiUrl;
|
||||
enableChunkMode();
|
||||
}
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "CompanyComplianceDataRangeReader";
|
||||
}
|
||||
@Override
|
||||
protected String getApiPath() {
|
||||
return "/RiskAndCompliance/UpdatedCompanyComplianceList";
|
||||
}
|
||||
protected String getApiKey() {
|
||||
return "COMPANY_COMPLIANCE_IMPORT_API";
|
||||
}
|
||||
@Override
|
||||
protected void resetCustomState() {
|
||||
this.currentBatchIndex = 0;
|
||||
this.allData = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<CompanyComplianceDto> fetchNextBatch() throws Exception{
|
||||
// 모든 배치 처리 완료 확인
|
||||
if (allData == null) {
|
||||
allData = callApiWithBatch();
|
||||
|
||||
if (allData == null || allData.isEmpty()) {
|
||||
log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName());
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize);
|
||||
}
|
||||
|
||||
// 2) 이미 끝까지 읽었으면 종료
|
||||
if (currentBatchIndex >= allData.size()) {
|
||||
log.info("[{}] 모든 배치 처리 완료", getReaderName());
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3) 이번 배치의 end 계산
|
||||
int end = Math.min(currentBatchIndex + batchSize, allData.size());
|
||||
|
||||
// 4) 현재 batch 리스트 잘라서 반환
|
||||
List<CompanyComplianceDto> batch = allData.subList(currentBatchIndex, end);
|
||||
|
||||
int batchNum = (currentBatchIndex / batchSize) + 1;
|
||||
int totalBatches = (int) Math.ceil((double) allData.size() / batchSize);
|
||||
|
||||
log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size());
|
||||
|
||||
// 다음 batch 인덱스 이동
|
||||
currentBatchIndex = end;
|
||||
updateApiCallStats(totalBatches, batchNum);
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterFetch(List<CompanyComplianceDto> data){
|
||||
try{
|
||||
if (data == null) {
|
||||
log.info("[{}] 배치 처리 성공", getReaderName());
|
||||
}
|
||||
}catch (Exception e){
|
||||
log.info("[{}] 배치 처리 실패", getReaderName());
|
||||
log.info("[{}] API 호출 종료", getReaderName());
|
||||
}
|
||||
}
|
||||
private List<CompanyComplianceDto> callApiWithBatch() {
|
||||
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
|
||||
// 부모 클래스의 공통 모듈 호출 (단 한 줄로 처리 가능)
|
||||
return executeListApiCall(
|
||||
maritimeServiceApiUrl,
|
||||
getApiPath(),
|
||||
params,
|
||||
new ParameterizedTypeReference<List<CompanyComplianceDto>>() {},
|
||||
batchApiLogService
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
package com.snp.batch.jobs.compliance.batch.reader;
|
||||
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import com.snp.batch.service.BatchDateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class ComplianceDataRangeReader extends BaseApiReader<ComplianceDto> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가
|
||||
private final BatchApiLogService batchApiLogService;
|
||||
String maritimeServiceApiUrl;
|
||||
private List<ComplianceDto> allData;
|
||||
private int currentBatchIndex = 0;
|
||||
private final int batchSize = 5000;
|
||||
|
||||
public ComplianceDataRangeReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) {
|
||||
super(webClient);
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.batchDateService = batchDateService;
|
||||
this.batchApiLogService = batchApiLogService;
|
||||
this.maritimeServiceApiUrl = maritimeServiceApiUrl;
|
||||
enableChunkMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "ComplianceDataRangeReader";
|
||||
}
|
||||
@Override
|
||||
protected String getApiPath() {
|
||||
return "/RiskAndCompliance/UpdatedComplianceList";
|
||||
}
|
||||
|
||||
protected String getApiKey() {
|
||||
return "COMPLIANCE_IMPORT_API";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void resetCustomState() {
|
||||
this.currentBatchIndex = 0;
|
||||
this.allData = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<ComplianceDto> fetchNextBatch() throws Exception {
|
||||
// 모든 배치 처리 완료 확인
|
||||
if (allData == null) {
|
||||
allData = callApiWithBatch();
|
||||
|
||||
if (allData == null || allData.isEmpty()) {
|
||||
log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName());
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize);
|
||||
}
|
||||
|
||||
// 2) 이미 끝까지 읽었으면 종료
|
||||
if (currentBatchIndex >= allData.size()) {
|
||||
log.info("[{}] 모든 배치 처리 완료", getReaderName());
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3) 이번 배치의 end 계산
|
||||
int end = Math.min(currentBatchIndex + batchSize, allData.size());
|
||||
|
||||
// 4) 현재 batch 리스트 잘라서 반환
|
||||
List<ComplianceDto> batch = allData.subList(currentBatchIndex, end);
|
||||
|
||||
int batchNum = (currentBatchIndex / batchSize) + 1;
|
||||
int totalBatches = (int) Math.ceil((double) allData.size() / batchSize);
|
||||
|
||||
log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size());
|
||||
|
||||
// 다음 batch 인덱스 이동
|
||||
currentBatchIndex = end;
|
||||
updateApiCallStats(totalBatches, batchNum);
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterFetch(List<ComplianceDto> data) {
|
||||
try{
|
||||
if (data == null) {
|
||||
log.info("[{}] 배치 처리 성공", getReaderName());
|
||||
}
|
||||
}catch (Exception e){
|
||||
log.info("[{}] 배치 처리 실패", getReaderName());
|
||||
log.info("[{}] API 호출 종료", getReaderName());
|
||||
}
|
||||
}
|
||||
|
||||
private List<ComplianceDto> callApiWithBatch() {
|
||||
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
|
||||
// 부모 클래스의 공통 모듈 호출 (단 한 줄로 처리 가능)
|
||||
return executeListApiCall(
|
||||
maritimeServiceApiUrl,
|
||||
getApiPath(),
|
||||
params,
|
||||
new ParameterizedTypeReference<List<ComplianceDto>>() {},
|
||||
batchApiLogService
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package com.snp.batch.jobs.compliance.batch.reader;
|
||||
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class ComplianceDataReader extends BaseApiReader<ComplianceDto> {
|
||||
|
||||
//TODO :
|
||||
// 1. Core20 IMO_NUMBER 전체 조회
|
||||
// 2. IMO번호에 대한 마지막 AIS 신호 요청 (1회 최대 5000개 : Chunk 단위로 반복)
|
||||
// 3. Response Data -> Core20에 업데이트 (Chunk 단위로 반복)
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private List<String> allImoNumbers;
|
||||
private int currentBatchIndex = 0;
|
||||
private final int batchSize = 100;
|
||||
|
||||
public ComplianceDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) {
|
||||
super(webClient);
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
enableChunkMode(); // ✨ Chunk 모드 활성화
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "ComplianceDataReader";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void resetCustomState() {
|
||||
this.currentBatchIndex = 0;
|
||||
this.allImoNumbers = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getApiPath() {
|
||||
return "/RiskAndCompliance/CompliancesByImos";
|
||||
}
|
||||
|
||||
private String getTargetTable(){
|
||||
return "snp_data.core20";
|
||||
}
|
||||
private String GET_CORE_IMO_LIST =
|
||||
// "SELECT ihslrorimoshipno FROM " + getTargetTable() + " ORDER BY ihslrorimoshipno";
|
||||
"select imo_number as ihslrorimoshipno from snp_data.ship_data order by imo_number";
|
||||
@Override
|
||||
protected void beforeFetch(){
|
||||
log.info("[{}] Core20 테이블에서 IMO 번호 조회 시작...", getReaderName());
|
||||
|
||||
allImoNumbers = jdbcTemplate.queryForList(GET_CORE_IMO_LIST, String.class);
|
||||
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
|
||||
log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size());
|
||||
log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize);
|
||||
log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches);
|
||||
|
||||
updateApiCallStats(totalBatches, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<ComplianceDto> fetchNextBatch() throws Exception {
|
||||
// 모든 배치 처리 완료 확인
|
||||
if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) {
|
||||
return null; // Job 종료
|
||||
}
|
||||
|
||||
// 현재 배치의 시작/끝 인덱스 계산
|
||||
int startIndex = currentBatchIndex;
|
||||
int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size());
|
||||
|
||||
// 현재 배치의 IMO 번호 추출 (100개)
|
||||
List<String> currentBatch = allImoNumbers.subList(startIndex, endIndex);
|
||||
|
||||
int currentBatchNumber = (currentBatchIndex / batchSize) + 1;
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
|
||||
log.info("[{}] 배치 {}/{} 처리 중 (IMO {} 개)...",
|
||||
getReaderName(), currentBatchNumber, totalBatches, currentBatch.size());
|
||||
|
||||
try {
|
||||
// IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...")
|
||||
String imoParam = String.join(",", currentBatch);
|
||||
|
||||
// API 호출
|
||||
List<ComplianceDto> response = callAisApiWithBatch(imoParam);
|
||||
|
||||
// 다음 배치로 인덱스 이동
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
// 응답 처리
|
||||
if (response != null) {
|
||||
// List<ComplianceDto> targets = response;
|
||||
log.info("[{}] 배치 {}/{} 완료: {} 건 조회",
|
||||
getReaderName(), currentBatchNumber, totalBatches, response.size());
|
||||
|
||||
// API 호출 통계 업데이트
|
||||
updateApiCallStats(totalBatches, currentBatchNumber);
|
||||
|
||||
// API 과부하 방지 (다음 배치 전 0.5초 대기)
|
||||
if (currentBatchIndex < allImoNumbers.size()) {
|
||||
Thread.sleep(500);
|
||||
}
|
||||
|
||||
return response;
|
||||
} else {
|
||||
log.warn("[{}] 배치 {}/{} 응답 없음",
|
||||
getReaderName(), currentBatchNumber, totalBatches);
|
||||
|
||||
// API 호출 통계 업데이트 (실패도 카운트)
|
||||
updateApiCallStats(totalBatches, currentBatchNumber);
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] 배치 {}/{} 처리 중 오류: {}",
|
||||
getReaderName(), currentBatchNumber, totalBatches, e.getMessage(), e);
|
||||
|
||||
// 오류 발생 시에도 다음 배치로 이동 (부분 실패 허용)
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
// 빈 리스트 반환 (Job 계속 진행)
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private List<ComplianceDto> callAisApiWithBatch(String imoNumbers) {
|
||||
String url = getApiPath() + "?imos=" + imoNumbers;
|
||||
log.debug("[{}] API 호출: {}", getReaderName(), url);
|
||||
return webClient.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.bodyToMono(new ParameterizedTypeReference<List<ComplianceDto>>() {})
|
||||
.block();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.snp.batch.jobs.compliance.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CompanyComplianceRepository {
|
||||
void saveCompanyComplianceAll(List<CompanyComplianceEntity> items);
|
||||
void saveCompanyComplianceHistoryAll(List<CompanyComplianceEntity> items);
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
package com.snp.batch.jobs.compliance.batch.repository;
|
||||
|
||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Types;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Repository("CompanyComplianceRepository")
|
||||
public class CompanyComplianceRepositoryImpl extends BaseJdbcRepository<CompanyComplianceEntity, Long> implements CompanyComplianceRepository{
|
||||
public CompanyComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<CompanyComplianceEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Long extractId(CompanyComplianceEntity entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getInsertSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getUpdateSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected String getUpdateSql(String targetTable, String targetIndex) {
|
||||
return """
|
||||
INSERT INTO new_snp.%s(
|
||||
owcode, lastupdated,
|
||||
companyoverallcompliancestatus, companyonaustraliansanctionlist, companyonbessanctionlist, companyoncanadiansanctionlist, companyinofacsanctionedcountry,
|
||||
companyinfatfjurisdiction, companyoneusanctionlist, companyonofacsanctionlist, companyonofacnonsdnsanctionlist, companyonofacssilist,
|
||||
companyonswisssanctionlist, companyonuaesanctionlist, companyonunsanctionlist, parentcompanycompliancerisk
|
||||
)VALUES(
|
||||
?, ?::timestamp, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)ON CONFLICT (%s)
|
||||
DO UPDATE SET
|
||||
companyoverallcompliancestatus = EXCLUDED.companyoverallcompliancestatus,
|
||||
companyonaustraliansanctionlist = EXCLUDED.companyonaustraliansanctionlist,
|
||||
companyonbessanctionlist = EXCLUDED.companyonbessanctionlist,
|
||||
companyoncanadiansanctionlist = EXCLUDED.companyoncanadiansanctionlist,
|
||||
companyinofacsanctionedcountry = EXCLUDED.companyinofacsanctionedcountry,
|
||||
companyinfatfjurisdiction = EXCLUDED.companyinfatfjurisdiction,
|
||||
companyoneusanctionlist = EXCLUDED.companyoneusanctionlist,
|
||||
companyonofacsanctionlist = EXCLUDED.companyonofacsanctionlist,
|
||||
companyonofacnonsdnsanctionlist = EXCLUDED.companyonofacnonsdnsanctionlist,
|
||||
companyonofacssilist = EXCLUDED.companyonofacssilist,
|
||||
companyonswisssanctionlist = EXCLUDED.companyonswisssanctionlist,
|
||||
companyonuaesanctionlist = EXCLUDED.companyonuaesanctionlist,
|
||||
companyonunsanctionlist = EXCLUDED.companyonunsanctionlist,
|
||||
parentcompanycompliancerisk = EXCLUDED.parentcompanycompliancerisk
|
||||
""".formatted(targetTable, targetIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setInsertParameters(PreparedStatement ps, CompanyComplianceEntity entity) throws Exception {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setUpdateParameters(PreparedStatement ps, CompanyComplianceEntity entity) throws Exception {
|
||||
int idx = 1;
|
||||
ps.setString(idx++, entity.getOwcode());
|
||||
ps.setString(idx++, entity.getLastUpdated());
|
||||
ps.setObject(idx++, entity.getCompanyOverallComplianceStatus(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnAustralianSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnBESSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnCanadianSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyInOFACSanctionedCountry(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyInFATFJurisdiction(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnEUSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnOFACSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnOFACNONSDNSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnOFACSSISanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnSwissSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnUAESanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getCompanyOnUNSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getParentCompanyNonCompliance(), Types.INTEGER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getEntityName() {
|
||||
return "CompanyComplianceEntity";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveCompanyComplianceAll(List<CompanyComplianceEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.batchUpdate(getUpdateSql("tb_company_compliance_info", "owcode"), items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setUpdateParameters(ps, entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 수정 파라미터 설정 실패", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveCompanyComplianceHistoryAll(List<CompanyComplianceEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.batchUpdate(getUpdateSql("tb_company_compliance_hstry", "owcode, lastupdated"), items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setUpdateParameters(ps, entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 수정 파라미터 설정 실패", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.snp.batch.jobs.compliance.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ComplianceRepository {
|
||||
void saveComplianceAll(List<ComplianceEntity> items);
|
||||
void saveComplianceHistoryAll(List<ComplianceEntity> items);
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
package com.snp.batch.jobs.compliance.batch.repository;
|
||||
|
||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Types;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Repository("ComplianceRepository")
|
||||
public class ComplianceRepositoryImpl extends BaseJdbcRepository<ComplianceEntity, Long> implements ComplianceRepository {
|
||||
|
||||
public ComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<ComplianceEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Long extractId(ComplianceEntity entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getInsertSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getUpdateSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected String getUpdateSql(String targetTable, String targetIndex) {
|
||||
return """
|
||||
INSERT INTO new_snp.%s (
|
||||
lrimoshipno, dateamended, legaloverall, shipbessanctionlist, shipdarkactivityindicator,
|
||||
shipdetailsnolongermaintained, shipeusanctionlist, shipflagdisputed, shipflagsanctionedcountry,
|
||||
shiphistoricalflagsanctionedcountry, shipofacnonsdnsanctionlist, shipofacsanctionlist,
|
||||
shipofacadvisorylist, shipownerofacssilist, shipowneraustraliansanctionlist, shipownerbessanctionlist,
|
||||
shipownercanadiansanctionlist, shipownereusanctionlist, shipownerfatfjurisdiction,
|
||||
shipownerhistoricalofacsanctionedcountry, shipownerofacsanctionlist, shipownerofacsanctionedcountry,
|
||||
shipownerparentcompanynoncompliance, shipownerparentfatfjurisdiction, shipownerparentofacsanctionedcountry,
|
||||
shipownerswisssanctionlist, shipowneruaesanctionlist, shipownerunsanctionlist,
|
||||
shipsanctionedcountryportcalllast12m, shipsanctionedcountryportcalllast3m, shipsanctionedcountryportcalllast6m,
|
||||
shipsecuritylegaldisputeevent, shipstspartnernoncompliancelast12m, shipswisssanctionlist,
|
||||
shipunsanctionlist
|
||||
)
|
||||
VALUES (
|
||||
?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (%s)
|
||||
DO UPDATE SET
|
||||
legaloverall = EXCLUDED.legaloverall,
|
||||
shipbessanctionlist = EXCLUDED.shipbessanctionlist,
|
||||
shipdarkactivityindicator = EXCLUDED.shipdarkactivityindicator,
|
||||
shipdetailsnolongermaintained = EXCLUDED.shipdetailsnolongermaintained,
|
||||
shipeusanctionlist = EXCLUDED.shipeusanctionlist,
|
||||
shipflagdisputed = EXCLUDED.shipflagdisputed,
|
||||
shipflagsanctionedcountry = EXCLUDED.shipflagsanctionedcountry,
|
||||
shiphistoricalflagsanctionedcountry = EXCLUDED.shiphistoricalflagsanctionedcountry,
|
||||
shipofacnonsdnsanctionlist = EXCLUDED.shipofacnonsdnsanctionlist,
|
||||
shipofacsanctionlist = EXCLUDED.shipofacsanctionlist,
|
||||
shipofacadvisorylist = EXCLUDED.shipofacadvisorylist,
|
||||
shipownerofacssilist = EXCLUDED.shipownerofacssilist,
|
||||
shipowneraustraliansanctionlist = EXCLUDED.shipowneraustraliansanctionlist,
|
||||
shipownerbessanctionlist = EXCLUDED.shipownerbessanctionlist,
|
||||
shipownercanadiansanctionlist = EXCLUDED.shipownercanadiansanctionlist,
|
||||
shipownereusanctionlist = EXCLUDED.shipownereusanctionlist,
|
||||
shipownerfatfjurisdiction = EXCLUDED.shipownerfatfjurisdiction,
|
||||
shipownerhistoricalofacsanctionedcountry = EXCLUDED.shipownerhistoricalofacsanctionedcountry,
|
||||
shipownerofacsanctionlist = EXCLUDED.shipownerofacsanctionlist,
|
||||
shipownerofacsanctionedcountry = EXCLUDED.shipownerofacsanctionedcountry,
|
||||
shipownerparentcompanynoncompliance = EXCLUDED.shipownerparentcompanynoncompliance,
|
||||
shipownerparentfatfjurisdiction = EXCLUDED.shipownerparentfatfjurisdiction,
|
||||
shipownerparentofacsanctionedcountry = EXCLUDED.shipownerparentofacsanctionedcountry,
|
||||
shipownerswisssanctionlist = EXCLUDED.shipownerswisssanctionlist,
|
||||
shipowneruaesanctionlist = EXCLUDED.shipowneruaesanctionlist,
|
||||
shipownerunsanctionlist = EXCLUDED.shipownerunsanctionlist,
|
||||
shipsanctionedcountryportcalllast12m = EXCLUDED.shipsanctionedcountryportcalllast12m,
|
||||
shipsanctionedcountryportcalllast3m = EXCLUDED.shipsanctionedcountryportcalllast3m,
|
||||
shipsanctionedcountryportcalllast6m = EXCLUDED.shipsanctionedcountryportcalllast6m,
|
||||
shipsecuritylegaldisputeevent = EXCLUDED.shipsecuritylegaldisputeevent,
|
||||
shipstspartnernoncompliancelast12m = EXCLUDED.shipstspartnernoncompliancelast12m,
|
||||
shipswisssanctionlist = EXCLUDED.shipswisssanctionlist,
|
||||
shipunsanctionlist = EXCLUDED.shipunsanctionlist
|
||||
""".formatted(targetTable, targetIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setInsertParameters(PreparedStatement ps, ComplianceEntity entity) throws Exception {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setUpdateParameters(PreparedStatement ps, ComplianceEntity entity) throws Exception {
|
||||
int idx = 1;
|
||||
ps.setString(idx++, entity.getLrimoShipNo());
|
||||
ps.setString(idx++, entity.getDateAmended());
|
||||
ps.setObject(idx++, entity.getLegalOverall(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipBESSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipDarkActivityIndicator(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipDetailsNoLongerMaintained(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipEUSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipFlagDisputed(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipFlagSanctionedCountry(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipHistoricalFlagSanctionedCountry(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOFACNonSDNSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOFACSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOFACAdvisoryList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerOFACSSIList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerAustralianSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerBESSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerCanadianSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerEUSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerFATFJurisdiction(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerHistoricalOFACSanctionedCountry(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerOFACSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerOFACSanctionedCountry(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerParentCompanyNonCompliance(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerParentFATFJurisdiction(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerParentOFACSanctionedCountry(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerSwissSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerUAESanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipOwnerUNSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast12m(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast3m(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast6m(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipSecurityLegalDisputeEvent(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipSTSPartnerNonComplianceLast12m(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipSwissSanctionList(), Types.INTEGER);
|
||||
ps.setObject(idx++, entity.getShipUNSanctionList(), Types.INTEGER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getEntityName() {
|
||||
return "ComplianceEntity";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveComplianceAll(List<ComplianceEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.batchUpdate(getUpdateSql("compliance", "lrimoshipno"), items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setUpdateParameters(ps, entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 수정 파라미터 설정 실패", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveComplianceHistoryAll(List<ComplianceEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.batchUpdate(getUpdateSql("compliance_history", "lrimoshipno, dateamended"), items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setUpdateParameters(ps, entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 수정 파라미터 설정 실패", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.snp.batch.jobs.compliance.batch.writer;
|
||||
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
|
||||
import com.snp.batch.jobs.compliance.batch.repository.CompanyComplianceRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CompanyComplianceDataWriter extends BaseWriter<CompanyComplianceEntity> {
|
||||
private final CompanyComplianceRepository complianceRepository;
|
||||
public CompanyComplianceDataWriter(CompanyComplianceRepository complianceRepository) {
|
||||
super("CompanyComplianceRepository");
|
||||
this.complianceRepository = complianceRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<CompanyComplianceEntity> items) throws Exception {
|
||||
complianceRepository.saveCompanyComplianceAll(items);
|
||||
complianceRepository.saveCompanyComplianceHistoryAll(items);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.snp.batch.jobs.compliance.batch.writer;
|
||||
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
|
||||
import com.snp.batch.jobs.compliance.batch.repository.ComplianceRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ComplianceDataWriter extends BaseWriter<ComplianceEntity> {
|
||||
private final ComplianceRepository complianceRepository;
|
||||
public ComplianceDataWriter(ComplianceRepository complianceRepository) {
|
||||
super("ComplianceRepository");
|
||||
this.complianceRepository = complianceRepository;
|
||||
}
|
||||
@Override
|
||||
protected void writeItems(List<ComplianceEntity> items) throws Exception {
|
||||
complianceRepository.saveComplianceAll(items);
|
||||
complianceRepository.saveComplianceHistoryAll(items);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
package com.snp.batch.jobs.event.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
|
||||
import com.snp.batch.jobs.event.batch.dto.EventDetailDto;
|
||||
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
|
||||
import com.snp.batch.jobs.event.batch.processor.EventDataProcessor;
|
||||
import com.snp.batch.jobs.event.batch.reader.EventDataReader;
|
||||
import com.snp.batch.jobs.event.batch.writer.EventDataWriter;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import com.snp.batch.service.BatchDateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class EventImportJobConfig extends BaseMultiStepJobConfig<EventDetailDto, EventDetailEntity> {
|
||||
private final EventDataProcessor eventDataProcessor;
|
||||
private final EventDataWriter eventDataWriter;
|
||||
private final EventDataReader eventDataReader;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final WebClient maritimeApiWebClient;
|
||||
private final BatchDateService batchDateService;
|
||||
private final BatchApiLogService batchApiLogService;
|
||||
|
||||
@Value("${app.batch.ship-api.url}")
|
||||
private String maritimeApiUrl;
|
||||
|
||||
protected String getApiKey() {return "EVENT_IMPORT_API";}
|
||||
protected String getBatchUpdateSql() {
|
||||
return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());}
|
||||
|
||||
@Override
|
||||
protected int getChunkSize() {
|
||||
return 1000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정
|
||||
}
|
||||
public EventImportJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
EventDataProcessor eventDataProcessor,
|
||||
EventDataWriter eventDataWriter,
|
||||
EventDataReader eventDataReader,
|
||||
JdbcTemplate jdbcTemplate,
|
||||
@Qualifier("maritimeApiWebClient")WebClient maritimeApiWebClient,
|
||||
BatchDateService batchDateService,
|
||||
BatchApiLogService batchApiLogService) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.maritimeApiWebClient = maritimeApiWebClient;
|
||||
this.eventDataProcessor = eventDataProcessor;
|
||||
this.eventDataWriter = eventDataWriter;
|
||||
this.eventDataReader = eventDataReader;
|
||||
this.batchDateService = batchDateService;
|
||||
this.batchApiLogService = batchApiLogService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() {
|
||||
return "EventImportJob";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "EventImportStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Job createJobFlow(JobBuilder jobBuilder) {
|
||||
return jobBuilder
|
||||
.start(eventImportStep())
|
||||
.next(eventLastExecutionUpdateStep())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@StepScope
|
||||
public EventDataReader eventDataReader(
|
||||
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출
|
||||
@Value("#{stepExecution.id}") Long stepExecutionId
|
||||
) {
|
||||
EventDataReader reader = new EventDataReader(maritimeApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeApiUrl);
|
||||
reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅
|
||||
return reader;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<EventDetailDto> createReader() {
|
||||
return eventDataReader;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<EventDetailDto, EventDetailEntity> createProcessor() {
|
||||
return eventDataProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<EventDetailEntity> createWriter() { return eventDataWriter; }
|
||||
|
||||
@Bean(name = "EventImportJob")
|
||||
public Job eventImportJob() {
|
||||
return job();
|
||||
}
|
||||
|
||||
@Bean(name = "EventImportStep")
|
||||
public Step eventImportStep() {
|
||||
return step();
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트
|
||||
*/
|
||||
@Bean
|
||||
public Tasklet eventLastExecutionUpdateTasklet() {
|
||||
return (contribution, chunkContext) -> {
|
||||
log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작");
|
||||
|
||||
jdbcTemplate.execute(getBatchUpdateSql());
|
||||
|
||||
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료");
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
@Bean(name = "EventLastExecutionUpdateStep")
|
||||
public Step eventLastExecutionUpdateStep() {
|
||||
return new StepBuilder("EventLastExecutionUpdateStep", jobRepository)
|
||||
.tasklet(eventLastExecutionUpdateTasklet(), transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.snp.batch.jobs.event.batch.entity.CargoEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CargoDto {
|
||||
@JsonProperty("EventID")
|
||||
private Integer eventID;
|
||||
@JsonProperty("Sequence")
|
||||
private String sequence;
|
||||
@JsonProperty("IHSLRorIMOShipNo")
|
||||
private String ihslrOrImoShipNo;
|
||||
@JsonProperty("Type")
|
||||
private String type;
|
||||
@JsonProperty("Quantity")
|
||||
private Integer quantity;
|
||||
@JsonProperty("UnitShort")
|
||||
private String unitShort;
|
||||
@JsonProperty("Unit")
|
||||
private String unit;
|
||||
@JsonProperty("Text")
|
||||
private String text;
|
||||
@JsonProperty("CargoDamage")
|
||||
private String cargoDamage;
|
||||
@JsonProperty("Dangerous")
|
||||
private String dangerous;
|
||||
|
||||
public CargoEntity toEntity() {
|
||||
return CargoEntity.builder()
|
||||
.eventID(this.eventID)
|
||||
.sequence(this.sequence)
|
||||
.ihslrOrImoShipNo(this.ihslrOrImoShipNo)
|
||||
.type(this.type)
|
||||
.unit(this.unit)
|
||||
.quantity(this.quantity)
|
||||
.unitShort(this.unitShort)
|
||||
.text(this.text)
|
||||
.cargoDamage(this.cargoDamage)
|
||||
.dangerous(this.dangerous)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class EventDetailDto {
|
||||
@JsonProperty("IncidentID")
|
||||
private Integer incidentID;
|
||||
@JsonProperty("EventID")
|
||||
private Long eventID;
|
||||
@JsonProperty("EventTypeID")
|
||||
private Integer eventTypeID;
|
||||
@JsonProperty("EventType")
|
||||
private String eventType;
|
||||
@JsonProperty("Significance")
|
||||
private String significance;
|
||||
@JsonProperty("Headline")
|
||||
private String headline;
|
||||
@JsonProperty("IHSLRorIMOShipNo")
|
||||
private String ihslrOrImoShipNo;
|
||||
@JsonProperty("VesselName")
|
||||
private String vesselName;
|
||||
@JsonProperty("VesselType")
|
||||
private String vesselType;
|
||||
@JsonProperty("VesselTypeDecode")
|
||||
private String vesselTypeDecode;
|
||||
@JsonProperty("VesselFlag")
|
||||
private String vesselFlagCode;
|
||||
@JsonProperty("Flag")
|
||||
private String vesselFlagDecode;
|
||||
@JsonProperty("CargoLoadingStatusCode")
|
||||
private String cargoLoadingStatusCode;
|
||||
@JsonProperty("VesselDWT")
|
||||
private Integer vesselDWT;
|
||||
@JsonProperty("VesselGT")
|
||||
private Integer vesselGT;
|
||||
@JsonProperty("LDTAtTime")
|
||||
private Integer ldtAtTime;
|
||||
@JsonProperty("DateOfBuild")
|
||||
private Integer dateOfBuild;
|
||||
@JsonProperty("RegisteredOwnerCodeAtTime")
|
||||
private String registeredOwnerCodeAtTime;
|
||||
@JsonProperty("RegisteredOwnerAtTime")
|
||||
private String registeredOwnerAtTime;
|
||||
@JsonProperty("RegisteredOwnerCoDAtTime")
|
||||
private String registeredOwnerCountryCodeAtTime;
|
||||
@JsonProperty("RegisteredOwnerCountryAtTime")
|
||||
private String registeredOwnerCountryAtTime;
|
||||
@JsonProperty("Weather")
|
||||
private String weather;
|
||||
@JsonProperty("EventTypeDetail")
|
||||
private String eventTypeDetail;
|
||||
@JsonProperty("EventTypeDetailID")
|
||||
private Integer eventTypeDetailID;
|
||||
@JsonProperty("CasualtyAction")
|
||||
private String casualtyAction;
|
||||
@JsonProperty("LocationName")
|
||||
private String locationName;
|
||||
@JsonProperty("TownName")
|
||||
private String townName;
|
||||
@JsonProperty("MarsdenGridReference")
|
||||
private Integer marsdenGridReference;
|
||||
@JsonProperty("EnvironmentLocation")
|
||||
private String environmentLocation;
|
||||
@JsonProperty("CasualtyZone")
|
||||
private String casualtyZone;
|
||||
@JsonProperty("CasualtyZoneCode")
|
||||
private String casualtyZoneCode;
|
||||
@JsonProperty("CountryCode")
|
||||
private String countryCode;
|
||||
@JsonProperty("AttemptedBoarding")
|
||||
private String attemptedBoarding;
|
||||
@JsonProperty("Description")
|
||||
private String description;
|
||||
@JsonProperty("Pollutant")
|
||||
private String pollutant;
|
||||
@JsonProperty("PollutantUnit")
|
||||
private String pollutantUnit;
|
||||
@JsonProperty("PollutantQuantity")
|
||||
private Double pollutantQuantity;
|
||||
@JsonProperty("PublishedDate")
|
||||
private String publishedDate;
|
||||
@JsonProperty("Component2")
|
||||
private String component2;
|
||||
@JsonProperty("FiredUpon")
|
||||
private String firedUpon;
|
||||
private String eventStartDate;
|
||||
private String eventEndDate;
|
||||
|
||||
@JsonProperty("Cargoes")
|
||||
private List<CargoDto> cargoes;
|
||||
|
||||
@JsonProperty("HumanCasualties")
|
||||
private List<HumanCasualtyDto> humanCasualties;
|
||||
|
||||
@JsonProperty("Relationships")
|
||||
private List<RelationshipDto> relationships;
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class EventDetailResponse {
|
||||
@JsonProperty("MaritimeEvent")
|
||||
private EventDetailDto eventDetailDto;
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class EventDto {
|
||||
@JsonProperty("IncidentID")
|
||||
private Long incidentId;
|
||||
|
||||
@JsonProperty("EventID")
|
||||
private Long eventId;
|
||||
|
||||
@JsonProperty("StartDate")
|
||||
private String startDate;
|
||||
|
||||
@JsonProperty("EventType")
|
||||
private String eventType;
|
||||
|
||||
@JsonProperty("Significance")
|
||||
private String significance;
|
||||
|
||||
@JsonProperty("Headline")
|
||||
private String headline;
|
||||
|
||||
@JsonProperty("EndDate")
|
||||
private String endDate;
|
||||
|
||||
@JsonProperty("IHSLRorIMOShipNo")
|
||||
private String ihslRorImoShipNo;
|
||||
|
||||
@JsonProperty("VesselName")
|
||||
private String vesselName;
|
||||
|
||||
@JsonProperty("VesselType")
|
||||
private String vesselType;
|
||||
|
||||
@JsonProperty("LocationName")
|
||||
private String locationName;
|
||||
|
||||
@JsonProperty("PublishedDate")
|
||||
private String publishedDate;
|
||||
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class EventPeriod {
|
||||
private String eventStartDate;
|
||||
private String eventEndDate;}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class EventResponse {
|
||||
@JsonProperty("EventCount")
|
||||
private Integer eventCount;
|
||||
|
||||
@JsonProperty("MaritimeEvents")
|
||||
private List<EventDto> MaritimeEvents;
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class HumanCasualtyDto {
|
||||
@JsonProperty("EventID")
|
||||
private Integer eventID;
|
||||
@JsonProperty("Scope")
|
||||
private String scope;
|
||||
@JsonProperty("Type")
|
||||
private String type;
|
||||
@JsonProperty("Qualifier")
|
||||
private String qualifier;
|
||||
@JsonProperty("Count")
|
||||
private Integer count;
|
||||
|
||||
public HumanCasualtyEntity toEntity() {
|
||||
return HumanCasualtyEntity.builder()
|
||||
.eventID(this.eventID)
|
||||
.scope(this.scope)
|
||||
.type(this.type)
|
||||
.qualifier(this.qualifier)
|
||||
.count(this.count)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.snp.batch.jobs.event.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.snp.batch.jobs.event.batch.entity.RelationshipEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RelationshipDto {
|
||||
@JsonProperty("IncidentID")
|
||||
private String incidentID;
|
||||
@JsonProperty("EventID")
|
||||
private Integer eventID;
|
||||
@JsonProperty("RelationshipType")
|
||||
private String relationshipType;
|
||||
@JsonProperty("RelationshipTypeCode")
|
||||
private String relationshipTypeCode;
|
||||
@JsonProperty("EventID2")
|
||||
private Integer eventID2;
|
||||
@JsonProperty("EventType")
|
||||
private String eventType;
|
||||
@JsonProperty("EventTypeCode")
|
||||
private String eventTypeCode;
|
||||
|
||||
public RelationshipEntity toEntity() {
|
||||
return RelationshipEntity.builder()
|
||||
.incidentID(this.incidentID)
|
||||
.eventID(this.eventID)
|
||||
.relationshipType(this.relationshipType)
|
||||
.relationshipTypeCode(this.relationshipTypeCode)
|
||||
.eventID2(this.eventID2)
|
||||
.eventType(this.eventType)
|
||||
.eventTypeCode(this.eventTypeCode)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.snp.batch.jobs.event.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class CargoEntity extends BaseEntity {
|
||||
private Integer eventID;
|
||||
private String sequence;
|
||||
private String ihslrOrImoShipNo;
|
||||
private String type;
|
||||
private Integer quantity;
|
||||
private String unitShort;
|
||||
private String unit;
|
||||
private String text;
|
||||
private String cargoDamage;
|
||||
private String dangerous;
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package com.snp.batch.jobs.event.batch.entity;
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class EventDetailEntity extends BaseEntity {
|
||||
private Integer incidentID;
|
||||
private Long eventID;
|
||||
private Integer eventTypeID;
|
||||
private String eventType;
|
||||
private String significance;
|
||||
private String headline;
|
||||
private String ihslrOrImoShipNo;
|
||||
private String vesselName;
|
||||
private String vesselType;
|
||||
private String vesselTypeDecode;
|
||||
private String vesselFlagCode;
|
||||
private String vesselFlagDecode;
|
||||
private String cargoLoadingStatusCode;
|
||||
private Integer vesselDWT;
|
||||
private Integer vesselGT;
|
||||
private Integer ldtAtTime;
|
||||
private Integer dateOfBuild;
|
||||
private String registeredOwnerCodeAtTime;
|
||||
private String registeredOwnerAtTime;
|
||||
private String registeredOwnerCountryCodeAtTime;
|
||||
private String registeredOwnerCountryAtTime;
|
||||
private String weather;
|
||||
private String eventTypeDetail;
|
||||
private Integer eventTypeDetailID;
|
||||
private String casualtyAction;
|
||||
private String locationName;
|
||||
private String townName;
|
||||
private Integer marsdenGridReference;
|
||||
private String environmentLocation;
|
||||
private String casualtyZone;
|
||||
private String casualtyZoneCode;
|
||||
private String countryCode;
|
||||
private String attemptedBoarding;
|
||||
private String description;
|
||||
private String pollutant;
|
||||
private String pollutantUnit;
|
||||
private Double pollutantQuantity;
|
||||
private String publishedDate;
|
||||
private String component2;
|
||||
private String firedUpon;
|
||||
|
||||
private String eventStartDate;
|
||||
private String eventEndDate;
|
||||
|
||||
private List<CargoEntity> cargoes;
|
||||
private List<HumanCasualtyEntity> humanCasualties;
|
||||
private List<RelationshipEntity> relationships;
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.snp.batch.jobs.event.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class EventEntity extends BaseEntity {
|
||||
|
||||
private Long incidentId;
|
||||
private Long eventId;
|
||||
private String startDate;
|
||||
private String eventType;
|
||||
private String significance;
|
||||
private String headline;
|
||||
private String endDate;
|
||||
private String ihslRorImoShipNo;
|
||||
private String vesselName;
|
||||
private String vesselType;
|
||||
private String locationName;
|
||||
private String publishedDate;
|
||||
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.snp.batch.jobs.event.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HumanCasualtyEntity extends BaseEntity {
|
||||
private Integer eventID;
|
||||
private String scope;
|
||||
private String type;
|
||||
private String qualifier;
|
||||
private Integer count;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.snp.batch.jobs.event.batch.entity;
|
||||
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class RelationshipEntity extends BaseEntity {
|
||||
private String incidentID;
|
||||
private Integer eventID;
|
||||
private String relationshipType;
|
||||
private String relationshipTypeCode;
|
||||
private Integer eventID2;
|
||||
private String eventType;
|
||||
private String eventTypeCode;
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package com.snp.batch.jobs.event.batch.processor;
|
||||
|
||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||
import com.snp.batch.jobs.event.batch.dto.CargoDto;
|
||||
import com.snp.batch.jobs.event.batch.dto.EventDetailDto;
|
||||
import com.snp.batch.jobs.event.batch.dto.HumanCasualtyDto;
|
||||
import com.snp.batch.jobs.event.batch.dto.RelationshipDto;
|
||||
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class EventDataProcessor extends BaseProcessor<EventDetailDto, EventDetailEntity> {
|
||||
@Override
|
||||
protected EventDetailEntity processItem(EventDetailDto dto) throws Exception {
|
||||
log.debug("Event 데이터 처리 시작: Event ID = {}", dto.getEventID());
|
||||
|
||||
EventDetailEntity entity = EventDetailEntity.builder()
|
||||
.eventID(dto.getEventID())
|
||||
.incidentID(dto.getIncidentID())
|
||||
.eventTypeID(dto.getEventTypeID())
|
||||
.eventType(dto.getEventType())
|
||||
.significance(dto.getSignificance())
|
||||
.headline(dto.getHeadline())
|
||||
.ihslrOrImoShipNo(dto.getIhslrOrImoShipNo())
|
||||
.vesselName(dto.getVesselName())
|
||||
.vesselType(dto.getVesselType())
|
||||
.vesselTypeDecode(dto.getVesselTypeDecode())
|
||||
.vesselFlagCode(dto.getVesselFlagCode())
|
||||
.vesselFlagDecode(dto.getVesselFlagDecode())
|
||||
.cargoLoadingStatusCode(dto.getCargoLoadingStatusCode())
|
||||
.vesselDWT(dto.getVesselDWT())
|
||||
.vesselGT(dto.getVesselGT())
|
||||
.ldtAtTime(dto.getLdtAtTime())
|
||||
.dateOfBuild(dto.getDateOfBuild())
|
||||
.registeredOwnerCodeAtTime(dto.getRegisteredOwnerCodeAtTime())
|
||||
.registeredOwnerAtTime(dto.getRegisteredOwnerAtTime())
|
||||
.registeredOwnerCountryCodeAtTime(dto.getRegisteredOwnerCountryCodeAtTime())
|
||||
.registeredOwnerCountryAtTime(dto.getRegisteredOwnerCountryAtTime())
|
||||
.weather(dto.getWeather())
|
||||
.eventTypeDetail(dto.getEventTypeDetail())
|
||||
.eventTypeDetailID(dto.getEventTypeDetailID())
|
||||
.casualtyAction(dto.getCasualtyAction())
|
||||
.locationName(dto.getLocationName())
|
||||
.townName(dto.getTownName())
|
||||
.marsdenGridReference(dto.getMarsdenGridReference())
|
||||
.environmentLocation(dto.getEnvironmentLocation())
|
||||
.casualtyZone(dto.getCasualtyZone())
|
||||
.casualtyZoneCode(dto.getCasualtyZoneCode())
|
||||
.countryCode(dto.getCountryCode())
|
||||
.attemptedBoarding(dto.getAttemptedBoarding())
|
||||
.description(dto.getDescription())
|
||||
.pollutant(dto.getPollutant())
|
||||
.pollutantUnit(dto.getPollutantUnit())
|
||||
.pollutantQuantity(dto.getPollutantQuantity())
|
||||
.publishedDate(dto.getPublishedDate())
|
||||
.component2(dto.getComponent2())
|
||||
.firedUpon(dto.getFiredUpon())
|
||||
.eventStartDate(dto.getEventStartDate())
|
||||
.eventEndDate(dto.getEventEndDate())
|
||||
.cargoes(dto.getCargoes() != null ?
|
||||
dto.getCargoes().stream().map(CargoDto::toEntity).collect(Collectors.toList()) : null)
|
||||
.humanCasualties(dto.getHumanCasualties() != null ?
|
||||
dto.getHumanCasualties().stream().map(HumanCasualtyDto::toEntity).collect(Collectors.toList()) : null)
|
||||
.relationships(dto.getRelationships() != null ?
|
||||
dto.getRelationships().stream().map(RelationshipDto::toEntity).collect(Collectors.toList()) : null)
|
||||
.build();
|
||||
|
||||
log.debug("Event 데이터 처리 완료: Event ID = {}", dto.getEventID());
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
package com.snp.batch.jobs.event.batch.reader;
|
||||
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.event.batch.dto.*;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import com.snp.batch.service.BatchDateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
public class EventDataReader extends BaseApiReader<EventDetailDto> {
|
||||
|
||||
private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가
|
||||
private final BatchApiLogService batchApiLogService;
|
||||
private final String maritimeApiUrl;
|
||||
private Map<Long, EventPeriod> eventPeriodMap;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public EventDataReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeApiUrl) {
|
||||
super(webClient);
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.batchDateService = batchDateService;
|
||||
this.batchApiLogService = batchApiLogService;
|
||||
this.maritimeApiUrl = maritimeApiUrl;
|
||||
enableChunkMode(); // ✨ Chunk 모드 활성화
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "EventDataReader";
|
||||
}
|
||||
@Override
|
||||
protected String getApiPath() {
|
||||
return "/MaritimeWCF/MaritimeAndTradeEventsService.svc/RESTFul/GetEventListByEventChangeDateRange";
|
||||
}
|
||||
protected String getEventDetailApiPath() {
|
||||
return "/MaritimeWCF/MaritimeAndTradeEventsService.svc/RESTFul/GetEventDataByEventID";
|
||||
}
|
||||
protected String getApiKey() {
|
||||
return "EVENT_IMPORT_API";
|
||||
}
|
||||
|
||||
// 배치 처리 상태
|
||||
private List<Long> eventIds;
|
||||
// DB 해시값을 저장할 맵
|
||||
private int currentBatchIndex = 0;
|
||||
private final int batchSize = 1;
|
||||
|
||||
@Override
|
||||
protected void resetCustomState() {
|
||||
this.currentBatchIndex = 0;
|
||||
this.eventIds = null;
|
||||
this.eventPeriodMap = new HashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeFetch() {
|
||||
// 1. 기간내 기록된 Event List 조회 (API 요청)
|
||||
EventResponse response = callEventApiWithBatch();
|
||||
// 2-1. Event List 에서 EventID List 추출
|
||||
// 2-2. Event List 에서 Map<EventId,Map<StartDate,EndDate>> 추출
|
||||
eventIds = extractEventIdList(response);
|
||||
log.info("EvnetId List 추출 완료 : {} 개", eventIds.size());
|
||||
|
||||
eventPeriodMap = response.getMaritimeEvents().stream()
|
||||
.filter(e -> e.getEventId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
EventDto::getEventId,
|
||||
e -> new EventPeriod(
|
||||
e.getStartDate(),
|
||||
e.getEndDate()
|
||||
)
|
||||
));
|
||||
|
||||
updateApiCallStats(eventIds.size(), 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<EventDetailDto> fetchNextBatch() throws Exception {
|
||||
// 3. EventID List 로 Event Detail 조회 (API요청) : 청크단위 실행
|
||||
// 모든 배치 처리 완료 확인
|
||||
if (eventIds == null || currentBatchIndex >= eventIds.size()) {
|
||||
return null; // Job 종료
|
||||
}
|
||||
|
||||
// 현재 배치의 시작/끝 인덱스 계산
|
||||
int startIndex = currentBatchIndex;
|
||||
int endIndex = Math.min(currentBatchIndex + batchSize, eventIds.size());
|
||||
|
||||
// 현재 배치의 IMO 번호 추출 (100개)
|
||||
List<Long> currentBatch = eventIds.subList(startIndex, endIndex);
|
||||
|
||||
int currentBatchNumber = (currentBatchIndex / batchSize) + 1;
|
||||
int totalBatches = (int) Math.ceil((double) eventIds.size() / batchSize);
|
||||
|
||||
try {
|
||||
// API 호출
|
||||
EventDetailResponse response = callEventDetailApiWithBatch(currentBatch.get(0));
|
||||
// 다음 배치로 인덱스 이동
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
List<EventDetailDto> eventDetailList = new ArrayList<>();
|
||||
|
||||
// 응답 처리
|
||||
if (response != null && response.getEventDetailDto() != null) {
|
||||
|
||||
// TODO: getEventDetailDto에 Map<EventId,Map<StartDate,EndDate>> 데이터 세팅
|
||||
EventDetailDto detailDto = response.getEventDetailDto();
|
||||
Long eventId = detailDto.getEventID();
|
||||
EventPeriod period = eventPeriodMap.get(eventId);
|
||||
|
||||
if (period != null) {
|
||||
detailDto.setEventStartDate(period.getEventStartDate());
|
||||
detailDto.setEventEndDate(period.getEventEndDate());
|
||||
}
|
||||
|
||||
eventDetailList.add(response.getEventDetailDto());
|
||||
log.info("[{}] 배치 {}/{} 완료: {} 건 조회",
|
||||
getReaderName(), currentBatchNumber, totalBatches, eventDetailList.size());
|
||||
|
||||
// API 호출 통계 업데이트
|
||||
updateApiCallStats(totalBatches, currentBatchNumber);
|
||||
|
||||
// API 과부하 방지 (다음 배치 전 1.0초 대기)
|
||||
if (currentBatchIndex < eventIds.size()) {
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
return eventDetailList;
|
||||
|
||||
} else {
|
||||
log.warn("[{}] 배치 {}/{} 응답 없음",
|
||||
getReaderName(), currentBatchNumber, totalBatches);
|
||||
|
||||
// API 호출 통계 업데이트 (실패도 카운트)
|
||||
updateApiCallStats(totalBatches, currentBatchNumber);
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] 배치 {}/{} 처리 중 오류: {}",
|
||||
getReaderName(), currentBatchNumber, totalBatches, e.getMessage(), e);
|
||||
|
||||
// 오류 발생 시에도 다음 배치로 이동 (부분 실패 허용)
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
// 빈 리스트 반환 (Job 계속 진행)
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterFetch(List<EventDetailDto> data) {
|
||||
int totalBatches = (int) Math.ceil((double) eventIds.size() / batchSize);
|
||||
try {
|
||||
if (data == null) {
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 Event ID에 대한 API 호출 종료",
|
||||
getReaderName(), eventIds.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.info("[{}] 전체 {} 개 배치 처리 실패", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 Event ID에 대한 API 호출 종료",
|
||||
getReaderName(), eventIds.size());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> extractEventIdList(EventResponse response) {
|
||||
if (response.getMaritimeEvents() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return response.getMaritimeEvents().stream()
|
||||
// ShipDto 객체에서 imoNumber 필드 (String 타입)를 추출
|
||||
.map(EventDto::getEventId)
|
||||
// IMO 번호가 null이 아닌 경우만 필터링 (선택 사항이지만 안전성을 위해)
|
||||
.filter(eventId -> eventId != null)
|
||||
// 추출된 String imoNumber들을 List<String>으로 수집
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private EventResponse callEventApiWithBatch() {
|
||||
Map<String, String> params = batchDateService.getDateRangeWithoutTimeParams(getApiKey());
|
||||
return executeSingleApiCall(
|
||||
maritimeApiUrl,
|
||||
getApiPath(),
|
||||
params,
|
||||
new ParameterizedTypeReference<EventResponse>() {},
|
||||
batchApiLogService,
|
||||
res -> res.getMaritimeEvents() != null ? (long) res.getMaritimeEvents().size() : 0L // 람다 적용
|
||||
);
|
||||
}
|
||||
|
||||
private EventDetailResponse callEventDetailApiWithBatch(Long eventId) {
|
||||
String url = getEventDetailApiPath();
|
||||
return webClient.get()
|
||||
.uri(url, uriBuilder -> uriBuilder
|
||||
// 맵에서 파라미터 값을 동적으로 가져와 세팅
|
||||
.queryParam("eventID", eventId)
|
||||
.build())
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, clientResponse ->
|
||||
clientResponse.bodyToMono(String.class) // 에러 바디를 문자열로 읽음
|
||||
.flatMap(errorBody -> {
|
||||
// 2. 로그에 상태 코드와 에러 메세지 출력
|
||||
log.error("[{}] API 호출 오류 발생!", getReaderName());
|
||||
log.error("[{}] ERROR CODE: {}, REASON: {}",
|
||||
getReaderName(),
|
||||
clientResponse.statusCode(),
|
||||
errorBody);
|
||||
|
||||
// 3. 상위로 예외 던지기 (배치 중단을 원할 경우)
|
||||
return Mono.error(new RuntimeException(
|
||||
String.format("API 호출 실패 (%s): %s", clientResponse.statusCode(), errorBody)
|
||||
));
|
||||
})
|
||||
)
|
||||
.bodyToMono(EventDetailResponse.class)
|
||||
.block();
|
||||
}
|
||||
|
||||
private LocalDateTime parseToLocalDate(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return LocalDateTime.parse(value);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.snp.batch.jobs.event.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.event.batch.entity.CargoEntity;
|
||||
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
|
||||
import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity;
|
||||
import com.snp.batch.jobs.event.batch.entity.RelationshipEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface EventRepository {
|
||||
void saveEventAll(List<EventDetailEntity> items);
|
||||
void saveCargoAll(List<CargoEntity> items);
|
||||
void saveHumanCasualtyAll(List<HumanCasualtyEntity> items);
|
||||
void saveRelationshipAll(List<RelationshipEntity> items);
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
package com.snp.batch.jobs.event.batch.repository;
|
||||
|
||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||
import com.snp.batch.jobs.event.batch.entity.CargoEntity;
|
||||
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
|
||||
import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity;
|
||||
import com.snp.batch.jobs.event.batch.entity.RelationshipEntity;
|
||||
import com.snp.batch.jobs.shipdetail.batch.entity.GroupBeneficialOwnerHistoryEntity;
|
||||
import com.snp.batch.jobs.shipdetail.batch.repository.ShipDetailSql;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Types;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Repository("EventRepository")
|
||||
public class EventRepositoryImpl extends BaseJdbcRepository<EventDetailEntity, Long> implements EventRepository {
|
||||
|
||||
public EventRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<EventDetailEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Long extractId(EventDetailEntity entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getInsertSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getUpdateSql() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setInsertParameters(PreparedStatement ps, EventDetailEntity entity) throws Exception {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getEntityName() {
|
||||
return "EventDetailEntity";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveEventAll(List<EventDetailEntity> items) {
|
||||
String entityName = "EventDetailEntity";
|
||||
String sql = EventSql.getEventDetailUpdateSql();
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setUpdateParameters(ps, (EventDetailEntity) entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 수정 파라미터 설정 실패", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveCargoAll(List<CargoEntity> items) {
|
||||
String entityName = "CargoEntity";
|
||||
String sql = EventSql.getEventCargoSql();
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setCargoInsertParameters(ps, (CargoEntity) entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveHumanCasualtyAll(List<HumanCasualtyEntity> items) {
|
||||
String entityName = "HumanCasualtyEntity";
|
||||
String sql = EventSql.getEventHumanCasualtySql();
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setHumanCasualtyInsertParameters(ps, (HumanCasualtyEntity) entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveRelationshipAll(List<RelationshipEntity> items) {
|
||||
String entityName = "RelationshipEntity";
|
||||
String sql = EventSql.getEventRelationshipSql();
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, items, items.size(),
|
||||
(ps, entity) -> {
|
||||
try {
|
||||
setRelationshipInsertParameters(ps, (RelationshipEntity) entity);
|
||||
} catch (Exception e) {
|
||||
log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setUpdateParameters(PreparedStatement ps, EventDetailEntity entity) throws Exception {
|
||||
int idx = 1;
|
||||
ps.setObject(idx++, entity.getEventID()); // event_id
|
||||
ps.setObject(idx++, entity.getIncidentID()); // incident_id (누락됨)
|
||||
ps.setObject(idx++, entity.getIhslrOrImoShipNo()); // ihslrorimoshipno (누락됨)
|
||||
ps.setObject(idx++, entity.getPublishedDate()); // published_date (누락됨)
|
||||
ps.setObject(idx++, entity.getEventStartDate()); // event_start_date
|
||||
ps.setObject(idx++, entity.getEventEndDate()); // event_end_date
|
||||
ps.setString(idx++, entity.getAttemptedBoarding()); // attempted_boarding
|
||||
ps.setString(idx++, entity.getCargoLoadingStatusCode());// cargo_loading_status_code
|
||||
ps.setString(idx++, entity.getCasualtyAction()); // casualty_action
|
||||
ps.setString(idx++, entity.getCasualtyZone()); // casualty_zone
|
||||
|
||||
// 11~20
|
||||
ps.setString(idx++, entity.getCasualtyZoneCode()); // casualty_zone_code
|
||||
ps.setString(idx++, entity.getComponent2()); // component2
|
||||
ps.setString(idx++, entity.getCountryCode()); // country_code
|
||||
ps.setObject(idx++, entity.getDateOfBuild()); // date_of_build (Integer)
|
||||
ps.setString(idx++, entity.getDescription()); // description
|
||||
ps.setString(idx++, entity.getEnvironmentLocation()); // environment_location
|
||||
ps.setString(idx++, entity.getLocationName()); // location_name (누락됨)
|
||||
ps.setObject(idx++, entity.getMarsdenGridReference()); // marsden_grid_reference (Integer)
|
||||
ps.setString(idx++, entity.getTownName()); // town_name
|
||||
ps.setString(idx++, entity.getEventType()); // event_type (누락됨)
|
||||
|
||||
// 21~30
|
||||
ps.setString(idx++, entity.getEventTypeDetail()); // event_type_detail
|
||||
ps.setObject(idx++, entity.getEventTypeDetailID()); // event_type_detail_id (Integer)
|
||||
ps.setObject(idx++, entity.getEventTypeID()); // event_type_id (Integer)
|
||||
ps.setString(idx++, entity.getFiredUpon()); // fired_upon
|
||||
ps.setString(idx++, entity.getHeadline()); // headline (누락됨)
|
||||
ps.setObject(idx++, entity.getLdtAtTime()); // ldt_at_time (Integer)
|
||||
ps.setString(idx++, entity.getSignificance()); // significance (누락됨)
|
||||
ps.setString(idx++, entity.getWeather()); // weather
|
||||
ps.setString(idx++, entity.getPollutant()); // pollutant
|
||||
ps.setObject(idx++, entity.getPollutantQuantity()); // pollutant_quantity (Double)
|
||||
|
||||
// 31~42
|
||||
ps.setString(idx++, entity.getPollutantUnit()); // pollutant_unit
|
||||
ps.setString(idx++, entity.getRegisteredOwnerCodeAtTime()); // registered_owner_code_at_time
|
||||
ps.setString(idx++, entity.getRegisteredOwnerAtTime()); // registered_owner_at_time
|
||||
ps.setString(idx++, entity.getRegisteredOwnerCountryCodeAtTime()); // registered_owner_country_code_at_time
|
||||
ps.setString(idx++, entity.getRegisteredOwnerCountryAtTime()); // registered_owner_country_at_time
|
||||
ps.setObject(idx++, entity.getVesselDWT()); // vessel_dwt (Integer)
|
||||
ps.setString(idx++, entity.getVesselFlagCode()); // vessel_flag_code
|
||||
ps.setString(idx++, entity.getVesselFlagDecode()); // vessel_flag_decode (누락됨)
|
||||
ps.setObject(idx++, entity.getVesselGT()); // vessel_gt (Integer)
|
||||
ps.setString(idx++, entity.getVesselName()); // vessel_name (누락됨)
|
||||
ps.setString(idx++, entity.getVesselType()); // vessel_type (누락됨)
|
||||
ps.setString(idx++, entity.getVesselTypeDecode()); // vessel_type_decode
|
||||
}
|
||||
private void setCargoInsertParameters(PreparedStatement ps, CargoEntity entity)throws Exception{
|
||||
int idx = 1;
|
||||
// INSERT 필드
|
||||
ps.setObject(idx++, entity.getEventID());
|
||||
ps.setString(idx++, entity.getSequence());
|
||||
ps.setString(idx++, entity.getIhslrOrImoShipNo());
|
||||
ps.setString(idx++, entity.getType());
|
||||
ps.setObject(idx++, entity.getQuantity()); // quantity 필드 (Entity에 없을 경우 null 처리)
|
||||
ps.setString(idx++, entity.getUnitShort()); // unit_short 필드
|
||||
ps.setString(idx++, entity.getUnit());
|
||||
ps.setString(idx++, entity.getCargoDamage());
|
||||
ps.setString(idx++, entity.getDangerous());
|
||||
ps.setString(idx++, entity.getText());
|
||||
}
|
||||
private void setHumanCasualtyInsertParameters(PreparedStatement ps, HumanCasualtyEntity entity)throws Exception{
|
||||
int idx = 1;
|
||||
ps.setObject(idx++, entity.getEventID());
|
||||
ps.setString(idx++, entity.getScope());
|
||||
ps.setString(idx++, entity.getType());
|
||||
ps.setString(idx++, entity.getQualifier());
|
||||
ps.setObject(idx++, entity.getCount());
|
||||
}
|
||||
private void setRelationshipInsertParameters(PreparedStatement ps, RelationshipEntity entity)throws Exception{
|
||||
int idx = 1;
|
||||
ps.setString(idx++, entity.getIncidentID());
|
||||
ps.setObject(idx++, entity.getEventID());
|
||||
ps.setString(idx++, entity.getRelationshipType());
|
||||
ps.setString(idx++, entity.getRelationshipTypeCode());
|
||||
ps.setObject(idx++, entity.getEventID2());
|
||||
ps.setString(idx++, entity.getEventType());
|
||||
ps.setString(idx++, entity.getEventTypeCode());
|
||||
}
|
||||
|
||||
private static void setStringOrNull(PreparedStatement ps, int index, String value) throws Exception {
|
||||
if (value == null) {
|
||||
ps.setNull(index, Types.VARCHAR);
|
||||
} else {
|
||||
ps.setString(index, value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Double 값을 PreparedStatement에 설정 (null 처리 포함)
|
||||
*/
|
||||
private static void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception {
|
||||
if (value == null) {
|
||||
ps.setNull(index, Types.DOUBLE);
|
||||
} else {
|
||||
ps.setDouble(index, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
package com.snp.batch.jobs.event.batch.repository;
|
||||
|
||||
public class EventSql {
|
||||
public static String getEventDetailUpdateSql(){
|
||||
return """
|
||||
INSERT INTO new_snp.event (
|
||||
event_id, incident_id, ihslrorimoshipno, published_date, event_start_date, event_end_date,
|
||||
attempted_boarding, cargo_loading_status_code, casualty_action,
|
||||
casualty_zone, casualty_zone_code, component2, country_code,
|
||||
date_of_build, description, environment_location, location_name,
|
||||
marsden_grid_reference, town_name, event_type, event_type_detail,
|
||||
event_type_detail_id, event_type_id, fired_upon, headline,
|
||||
ldt_at_time, significance, weather, pollutant, pollutant_quantity,
|
||||
pollutant_unit, registered_owner_code_at_time, registered_owner_at_time,
|
||||
registered_owner_country_code_at_time, registered_owner_country_at_time,
|
||||
vessel_dwt, vessel_flag_code, vessel_flag_decode, vessel_gt,
|
||||
vessel_name, vessel_type, vessel_type_decode
|
||||
)
|
||||
VALUES (
|
||||
?, ?, ?, ?::timestamptz,?::timestamptz,?::timestamptz, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (event_id)
|
||||
DO UPDATE SET
|
||||
incident_id = EXCLUDED.incident_id,
|
||||
ihslrorimoshipno = EXCLUDED.ihslrorimoshipno,
|
||||
published_date = EXCLUDED.published_date,
|
||||
event_start_date = EXCLUDED.event_start_date,
|
||||
event_end_date = EXCLUDED.event_end_date,
|
||||
attempted_boarding = EXCLUDED.attempted_boarding,
|
||||
cargo_loading_status_code = EXCLUDED.cargo_loading_status_code,
|
||||
casualty_action = EXCLUDED.casualty_action,
|
||||
casualty_zone = EXCLUDED.casualty_zone,
|
||||
casualty_zone_code = EXCLUDED.casualty_zone_code,
|
||||
component2 = EXCLUDED.component2,
|
||||
country_code = EXCLUDED.country_code,
|
||||
date_of_build = EXCLUDED.date_of_build,
|
||||
description = EXCLUDED.description,
|
||||
environment_location = EXCLUDED.environment_location,
|
||||
location_name = EXCLUDED.location_name,
|
||||
marsden_grid_reference = EXCLUDED.marsden_grid_reference,
|
||||
town_name = EXCLUDED.town_name,
|
||||
event_type = EXCLUDED.event_type,
|
||||
event_type_detail = EXCLUDED.event_type_detail,
|
||||
event_type_detail_id = EXCLUDED.event_type_detail_id,
|
||||
event_type_id = EXCLUDED.event_type_id,
|
||||
fired_upon = EXCLUDED.fired_upon,
|
||||
headline = EXCLUDED.headline,
|
||||
ldt_at_time = EXCLUDED.ldt_at_time,
|
||||
significance = EXCLUDED.significance,
|
||||
weather = EXCLUDED.weather,
|
||||
pollutant = EXCLUDED.pollutant,
|
||||
pollutant_quantity = EXCLUDED.pollutant_quantity,
|
||||
pollutant_unit = EXCLUDED.pollutant_unit,
|
||||
registered_owner_code_at_time = EXCLUDED.registered_owner_code_at_time,
|
||||
registered_owner_at_time = EXCLUDED.registered_owner_at_time,
|
||||
registered_owner_country_code_at_time = EXCLUDED.registered_owner_country_code_at_time,
|
||||
registered_owner_country_at_time = EXCLUDED.registered_owner_country_at_time,
|
||||
vessel_dwt = EXCLUDED.vessel_dwt,
|
||||
vessel_flag_code = EXCLUDED.vessel_flag_code,
|
||||
vessel_flag_decode = EXCLUDED.vessel_flag_decode,
|
||||
vessel_gt = EXCLUDED.vessel_gt,
|
||||
vessel_name = EXCLUDED.vessel_name,
|
||||
vessel_type = EXCLUDED.vessel_type,
|
||||
vessel_type_decode = EXCLUDED.vessel_type_decode
|
||||
""";
|
||||
}
|
||||
|
||||
public static String getEventCargoSql(){
|
||||
return """
|
||||
INSERT INTO new_snp.event_cargo (
|
||||
event_id, "sequence", ihslrorimoshipno, "type", quantity,
|
||||
unit_short, unit, cargo_damage, dangerous, "text"
|
||||
)
|
||||
VALUES (
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (event_id, ihslrorimoshipno, "type", "sequence")
|
||||
DO UPDATE SET
|
||||
quantity = EXCLUDED.quantity,
|
||||
unit_short = EXCLUDED.unit_short,
|
||||
unit = EXCLUDED.unit,
|
||||
cargo_damage = EXCLUDED.cargo_damage,
|
||||
dangerous = EXCLUDED.dangerous,
|
||||
"text" = EXCLUDED."text"
|
||||
""";
|
||||
}
|
||||
|
||||
public static String getEventRelationshipSql(){
|
||||
return """
|
||||
INSERT INTO new_snp.event_relationship (
|
||||
incident_id, event_id, relationship_type, relationship_type_code,
|
||||
event_id_2, event_type, event_type_code
|
||||
)
|
||||
VALUES (
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
)
|
||||
ON CONFLICT (incident_id, event_id, event_id_2, event_type_code, relationship_type_code)
|
||||
DO UPDATE SET
|
||||
relationship_type = EXCLUDED.relationship_type,
|
||||
event_type = EXCLUDED.event_type
|
||||
""";
|
||||
}
|
||||
|
||||
public static String getEventHumanCasualtySql(){
|
||||
return """
|
||||
INSERT INTO new_snp.event_humancasualty (
|
||||
event_id, "scope", "type", qualifier, "count"
|
||||
)
|
||||
VALUES (
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (event_id, "scope", "type", qualifier)
|
||||
DO UPDATE SET
|
||||
"count" = EXCLUDED."count"
|
||||
""";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.snp.batch.jobs.event.batch.writer;
|
||||
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
|
||||
import com.snp.batch.jobs.event.batch.repository.EventRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class EventDataWriter extends BaseWriter<EventDetailEntity> {
|
||||
private final EventRepository eventRepository;
|
||||
public EventDataWriter(EventRepository eventRepository) {
|
||||
super("EventRepository");
|
||||
this.eventRepository = eventRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<EventDetailEntity> items) throws Exception {
|
||||
|
||||
if (CollectionUtils.isEmpty(items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. EventDetail 메인 데이터 저장
|
||||
eventRepository.saveEventAll(items);
|
||||
|
||||
for (EventDetailEntity event : items) {
|
||||
// 2. CargoEntityList Save
|
||||
if (!CollectionUtils.isEmpty(event.getCargoes())) {
|
||||
eventRepository.saveCargoAll(event.getCargoes());
|
||||
}
|
||||
// 3. HumanCasualtyEntityList Save
|
||||
if (!CollectionUtils.isEmpty(event.getHumanCasualties())) {
|
||||
eventRepository.saveHumanCasualtyAll(event.getHumanCasualties());
|
||||
}
|
||||
// 4. RelationshipEntityList Save
|
||||
if (!CollectionUtils.isEmpty(event.getRelationships())) {
|
||||
eventRepository.saveRelationshipAll(event.getRelationships());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Batch Write 완료: {} 건의 Event 처리됨", items.size());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user