Merge branch 'develop' into dev_ship_movement

This commit is contained in:
hyojin kim 2025-12-10 12:33:57 +09:00
커밋 2a0a80098d
22개의 변경된 파일801개의 추가작업 그리고 150개의 파일을 삭제

파일 보기

@ -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) {

파일 보기

@ -178,7 +178,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 +206,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 +226,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) {

파일 보기

@ -6,6 +6,7 @@ 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;
@ -43,6 +44,7 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
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;
@ -55,11 +57,13 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
PlatformTransactionManager transactionManager,
AisTargetDataProcessor aisTargetDataProcessor,
AisTargetDataWriter aisTargetDataWriter,
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient) {
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient,
Core20CacheManager core20CacheManager) {
super(jobRepository, transactionManager);
this.aisTargetDataProcessor = aisTargetDataProcessor;
this.aisTargetDataWriter = aisTargetDataWriter;
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
this.core20CacheManager = core20CacheManager;
}
@Override
@ -101,16 +105,29 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
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 완료 - 상태: {}, 처리 건수: {}",
log.info("[{}] Job 완료 - 상태: {}, 처리 건수: {}, Core20 캐시 크기: {}",
getJobName(),
jobExecution.getStatus(),
jobExecution.getStepExecutions().stream()
.mapToLong(se -> se.getWriteCount())
.sum());
.sum(),
core20CacheManager.size());
}
});
}

파일 보기

@ -82,4 +82,21 @@ public class AisTargetEntity extends BaseEntity {
// ========== 타임스탬프 ==========
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;
}

파일 보기

@ -46,7 +46,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
received_date, collected_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326),
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
@ -203,9 +203,9 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
SELECT DISTINCT ON (mmsi) *
FROM %s
WHERE message_timestamp BETWEEN ? AND ?
AND public.ST_DWithin(
AND ST_DWithin(
geom::geography,
public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326)::geography,
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
?
)
ORDER BY mmsi, message_timestamp DESC

파일 보기

@ -4,6 +4,7 @@ import com.snp.batch.common.batch.writer.BaseWriter;
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.classifier.AisClassTypeClassifier;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@ -13,8 +14,9 @@ import java.util.List;
* AIS Target 데이터 Writer
*
* 동작:
* - UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp)
* - 동시에 캐시에도 최신 위치 정보 업데이트
* 1. UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp)
* 2. ClassType 분류 (Core20 캐시 기반 A/B 분류)
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
*/
@Slf4j
@Component
@ -22,23 +24,30 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
private final AisTargetRepository aisTargetRepository;
private final AisTargetCacheManager cacheManager;
private final AisClassTypeClassifier classTypeClassifier;
public AisTargetDataWriter(
AisTargetRepository aisTargetRepository,
AisTargetCacheManager cacheManager) {
AisTargetCacheManager cacheManager,
AisClassTypeClassifier classTypeClassifier) {
super("AisTarget");
this.aisTargetRepository = aisTargetRepository;
this.cacheManager = cacheManager;
this.classTypeClassifier = classTypeClassifier;
}
@Override
protected void writeItems(List<AisTargetEntity> items) throws Exception {
log.debug("AIS Target 데이터 저장 시작: {} 건", items.size());
// 1. DB 저장
// 1. DB 저장 (classType 없이 원본 데이터만 저장)
aisTargetRepository.batchUpsert(items);
// 2. 캐시 업데이트 (최신 위치 정보)
// 2. ClassType 분류 (캐시 저장 전에 분류)
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
classTypeClassifier.classifyAll(items);
// 3. 캐시 업데이트 (classType, core20Mmsi 포함)
cacheManager.putAll(items);
log.debug("AIS Target 데이터 저장 완료: {} 건 (캐시 크기: {})",

파일 보기

@ -2,10 +2,12 @@ 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;
@ -16,6 +18,7 @@ import java.util.stream.Collectors;
* - SOG, COG, Heading: 숫자 범위 조건
* - Destination: 문자열 부분 일치
* - Status: 다중 선택 일치
* - ClassType: 선박 클래스 타입 (A/B)
*/
@Slf4j
@Component
@ -45,6 +48,7 @@ public class AisTargetFilterUtil {
.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;
@ -54,6 +58,54 @@ public class AisTargetFilterUtil {
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 (속도) 조건 매칭
*/
@ -150,4 +202,28 @@ public class AisTargetFilterUtil {
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);
}
}

파일 보기

@ -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);
}
}

파일 보기

@ -74,11 +74,23 @@ public class AisTargetController {
@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인 경우에만 존재할 있음)
공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다.
"""
@ -92,16 +104,19 @@ public class AisTargetController {
@Parameter(description = "중심 위도", example = "35.0")
@RequestParam(required = false) Double centerLat,
@Parameter(description = "반경 (미터)", example = "50000")
@RequestParam(required = false) Double radiusMeters) {
@RequestParam(required = false) Double radiusMeters,
@Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A")
@RequestParam(required = false) String classType) {
log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}",
minutes, centerLon, centerLat, radiusMeters);
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);
@ -113,13 +128,33 @@ public class AisTargetController {
@Operation(
summary = "시간/공간 범위로 선박 검색 (POST)",
description = "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: {}",
request.getMinutes(), request.hasAreaFilter());
log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}",
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
List<AisTargetResponseDto> result = aisTargetService.search(request);
return ResponseEntity.ok(ApiResponse.success(

파일 보기

@ -121,6 +121,16 @@ public class AisTargetFilterRequest {
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() {
@ -143,8 +153,13 @@ public class AisTargetFilterRequest {
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();
|| hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter();
}
}

파일 보기

@ -2,6 +2,7 @@ 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;
@ -17,6 +18,7 @@ import java.time.OffsetDateTime;
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "AIS Target 응답")
public class AisTargetResponseDto {
// 선박 식별 정보
@ -51,8 +53,26 @@ public class AisTargetResponseDto {
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 변환
*/
@ -82,6 +102,8 @@ public class AisTargetResponseDto {
.messageTimestamp(entity.getMessageTimestamp())
.receivedDate(entity.getReceivedDate())
.source(source)
.classType(entity.getClassType())
.core20Mmsi(entity.getCore20Mmsi())
.build();
}
}

파일 보기

@ -39,10 +39,28 @@ public class AisTargetSearchRequest {
@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"));
}
}

파일 보기

@ -122,11 +122,12 @@ public class AisTargetService {
* 전략:
* 1. 캐시에서 시간 범위 데이터 조회
* 2. 공간 필터 있으면 JTS로 필터링
* 3. 캐시 데이터가 없으면 DB Fallback
* 3. ClassType 필터 있으면 적용
* 4. 캐시 데이터가 없으면 DB Fallback
*/
public List<AisTargetResponseDto> search(AisTargetSearchRequest request) {
log.debug("선박 검색 - minutes: {}, hasArea: {}",
request.getMinutes(), request.hasAreaFilter());
log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}",
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
long startTime = System.currentTimeMillis();
@ -154,6 +155,11 @@ public class AisTargetService {
);
}
// 3. ClassType 필터 적용
if (request.hasClassTypeFilter()) {
entities = filterUtil.filterByClassType(entities, request);
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
source, entities.size(), elapsed);

파일 보기

@ -65,7 +65,7 @@ public class RiskRepositoryImpl extends BaseJdbcRepository<RiskEntity, Long> imp
VALUES (
?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N'
)
ON CONFLICT (lrno)
ON CONFLICT (lrno, lastupdated)
DO UPDATE SET
riskdatamaintained = EXCLUDED.riskdatamaintained,
dayssincelastseenonais = EXCLUDED.dayssincelastseenonais,

파일 보기

@ -58,7 +58,7 @@ public class ComplianceRepositoryImpl extends BaseJdbcRepository<ComplianceEntit
VALUES (
?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N'
)
ON CONFLICT (lrimoshipno)
ON CONFLICT (lrimoshipno, dateamended)
DO UPDATE SET
legaloverall = EXCLUDED.legaloverall,
shipbessanctionlist = EXCLUDED.shipbessanctionlist,

파일 보기

@ -4,9 +4,9 @@ spring:
# PostgreSQL Database Configuration
datasource:
url: jdbc:postgresql://10.26.252.39:5432/mdadb?currentSchema=snp_data
username: mda
password: mda#8932
url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=snp_data,public
username: snp
password: snp#8932
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
@ -57,7 +57,7 @@ spring:
server:
port: 8041
servlet:
context-path: /
context-path: /snp-api
# Actuator Configuration
management:
@ -69,18 +69,9 @@ management:
health:
show-details: always
# Logging Configuration
# Logging Configuration (logback-spring.xml에서 상세 설정)
logging:
level:
root: INFO
com.snp.batch: DEBUG
org.springframework.batch: DEBUG
org.springframework.jdbc: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/snp-batch.log
config: classpath:logback-spring.xml
# Custom Application Properties
app:
@ -100,3 +91,44 @@ app:
schedule:
enabled: true
cron: "0 0 * * * ?" # Every hour
# AIS Target 배치 설정
ais-target:
since-seconds: 60 # API 조회 범위 (초)
chunk-size: 5000 # 배치 청크 크기
schedule:
cron: "15 * * * * ?" # 매 분 15초 실행
# AIS Target 캐시 설정
ais-target-cache:
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
max-size: 300000 # 최대 캐시 크기 - 30만 건
# ClassType 분류 설정
class-type:
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
core20:
schema: snp_data # 스키마명
table: core20 # 테이블명
imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL)
mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE)
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
daily-tables:
- schema: snp_data
table-name: ais_target
partition-column: message_timestamp
periods-ahead: 3 # 미리 생성할 일수
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
monthly-tables: [] # 현재 없음
# 기본 보관기간
retention:
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
# 개별 테이블 보관기간 설정 (옵션)
custom:
# - table-name: ais_target
# retention-days: 30 # ais_target만 30일 보관

파일 보기

@ -55,7 +55,7 @@ spring:
# Server Configuration
server:
port: 9000
port: 8041
servlet:
context-path: /snp-api
@ -74,6 +74,7 @@ management:
logging:
config: classpath:logback-spring.xml
# Custom Application Properties
app:
batch:
@ -92,3 +93,44 @@ app:
schedule:
enabled: true
cron: "0 0 * * * ?" # Every hour
# AIS Target 배치 설정
ais-target:
since-seconds: 60 # API 조회 범위 (초)
chunk-size: 5000 # 배치 청크 크기
schedule:
cron: "15 * * * * ?" # 매 분 15초 실행
# AIS Target 캐시 설정
ais-target-cache:
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
max-size: 300000 # 최대 캐시 크기 - 30만 건
# ClassType 분류 설정
class-type:
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
core20:
schema: snp_data # 스키마명
table: core20 # 테이블명
imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL)
mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE)
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
daily-tables:
- schema: snp_data
table-name: ais_target
partition-column: message_timestamp
periods-ahead: 3 # 미리 생성할 일수
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
monthly-tables: [] # 현재 없음
# 기본 보관기간
retention:
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
# 개별 테이블 보관기간 설정 (옵션)
custom:
# - table-name: ais_target
# retention-days: 30 # ais_target만 30일 보관

파일 보기

@ -1,101 +0,0 @@
spring:
application:
name: snp-batch
# PostgreSQL Database Configuration
datasource:
url: jdbc:postgresql://211.208.115.83:5432/snpdb
username: snp
password: snp#8932
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
# JPA Configuration
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
default_schema: snp_data
# Batch Configuration
batch:
jdbc:
initialize-schema: never # Changed to 'never' as tables already exist
job:
enabled: false # Prevent auto-run on startup
# Thymeleaf Configuration
thymeleaf:
cache: false
prefix: classpath:/templates/
suffix: .html
# Quartz Scheduler Configuration - Using JDBC Store for persistence
quartz:
job-store-type: jdbc # JDBC store for schedule persistence
jdbc:
initialize-schema: always # Create Quartz tables if not exist
properties:
org.quartz.scheduler.instanceName: SNPBatchScheduler
org.quartz.scheduler.instanceId: AUTO
org.quartz.threadPool.threadCount: 10
org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
org.quartz.jobStore.tablePrefix: QRTZ_
org.quartz.jobStore.isClustered: false
org.quartz.jobStore.misfireThreshold: 60000
# Server Configuration
server:
port: 8041
servlet:
context-path: /
# Actuator Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,batch
endpoint:
health:
show-details: always
# Logging Configuration
logging:
level:
root: INFO
com.snp.batch: DEBUG
org.springframework.batch: DEBUG
org.springframework.jdbc: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/snp-batch.log
# Custom Application Properties
app:
batch:
chunk-size: 1000
api:
url: https://api.example.com/data
timeout: 30000
ship-api:
url: https://shipsapi.maritime.spglobal.com
username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade
password: 2LLzSJNqtxWVD8zC
ais-api:
url: https://aisapi.maritime.spglobal.com
webservice-api:
url: https://webservices.maritime.spglobal.com
schedule:
enabled: true
cron: "0 0 * * * ?" # Every hour

파일 보기

@ -4,7 +4,7 @@ spring:
# PostgreSQL Database Configuration
datasource:
url: jdbc:postgresql://211.208.115.83:5432/snpdb
url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=snp_data,public
username: snp
password: snp#8932
driver-class-name: org.postgresql.Driver
@ -103,6 +103,17 @@ app:
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
max-size: 300000 # 최대 캐시 크기 - 30만 건
# ClassType 분류 설정
class-type:
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
core20:
schema: snp_data # 스키마명
table: core20 # 테이블명
imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL)
mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE)
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)

파일 보기

@ -410,8 +410,8 @@
if (!confirmUpdate) {
return;
}
method = 'PUT';
url = contextPath + `api/batch/schedules/${jobName}`;
method = 'POST';
url = contextPath + `api/batch/schedules/${jobName}/update`;
}
const response = await fetch(url, {
@ -455,7 +455,7 @@
try {
const response = await fetch(contextPath + `api/batch/schedules/${jobName}/toggle`, {
method: 'PATCH',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
@ -482,8 +482,8 @@
}
try {
const response = await fetch(contextPath + `api/batch/schedules/${jobName}`, {
method: 'DELETE'
const response = await fetch(contextPath + `api/batch/schedules/${jobName}/delete`, {
method: 'POST'
});
const result = await response.json();