diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md
index 88995a1..d251e59 100644
--- a/docs/RELEASE-NOTES.md
+++ b/docs/RELEASE-NOTES.md
@@ -4,7 +4,10 @@
## [Unreleased]
+## [2026-03-25]
+
### 추가
+- 배치 로그 관리 정책 수립 및 정리 배치 작업 개발 (LogCleanupJob) (#100)
- IMO Meta Table 관리 배치 작업 개발 (All IMO Import + Delete Flag Update) (#80)
- Risk 상세 데이터 수집 배치 프로세스 추가 (RisksByImos API, 파티션 병렬 처리) (#65)
- 배치 모니터링 React SPA 전환 및 10대 기능 강화
@@ -61,6 +64,8 @@
- 실패 건 수동 재수집 시 414 Request-URI Too Long 오류 수정 (#71)
### 변경
+- AIS 수집 및 서비스 API 제거 (배치 분리에 따른 코드 정리, ~7,000 LOC 삭제) (#99)
+- API URL 환경별 중복 제거 (application.yml 공통 관리)
- RiskRangeImportJob API URL 변경 및 저장 테이블 통합 (#86)
- RiskDetailImportJob IMO 조회 대상을 tb_ship_default_info로 변경 (#81)
- 파티션 스텝 프로세스 공통 모듈화 (StringListPartitioner, BasePartitionedJobConfig, LastExecutionUpdateTasklet) (#73)
diff --git a/frontend/src/pages/Executions.tsx b/frontend/src/pages/Executions.tsx
index 0e77bf8..8c69792 100644
--- a/frontend/src/pages/Executions.tsx
+++ b/frontend/src/pages/Executions.tsx
@@ -93,9 +93,6 @@ export default function Executions() {
return map;
}, [displayNames]);
- const aisJobs = useMemo(() => jobs.filter(j => j.toLowerCase().startsWith('ais')), [jobs]);
- const nonAisJobs = useMemo(() => jobs.filter(j => !j.toLowerCase().startsWith('ais')), [jobs]);
-
const loadJobs = useCallback(async () => {
try {
const data = await batchApi.getJobs();
@@ -330,26 +327,6 @@ export default function Executions() {
>
전체
- setSelectedJobs(nonAisJobs)}
- className={`px-2 py-1 text-xs rounded-md transition-colors ${
- selectedJobs.length > 0 && selectedJobs.length === nonAisJobs.length && selectedJobs.every(j => nonAisJobs.includes(j))
- ? 'bg-wing-accent text-white'
- : 'bg-wing-card text-wing-muted hover:bg-wing-hover'
- }`}
- >
- AIS 제외
-
- setSelectedJobs([...aisJobs])}
- className={`px-2 py-1 text-xs rounded-md transition-colors ${
- selectedJobs.length > 0 && selectedJobs.length === aisJobs.length && selectedJobs.every(j => aisJobs.includes(j))
- ? 'bg-wing-accent text-white'
- : 'bg-wing-card text-wing-muted hover:bg-wing-hover'
- }`}
- >
- AIS만
-
{selectedJobs.length > 0 && (
2.3.0
-
-
- org.springframework.kafka
- spring-kafka
-
@@ -124,12 +119,6 @@
3.1.8
-
-
- org.locationtech.jts
- jts-core
- 1.19.0
-
diff --git a/sql/chnprmship-cache-diag.sql b/sql/chnprmship-cache-diag.sql
deleted file mode 100644
index 4f4c2d9..0000000
--- a/sql/chnprmship-cache-diag.sql
+++ /dev/null
@@ -1,149 +0,0 @@
--- ============================================================
--- ChnPrmShip 캐시 검증 진단 쿼리
--- 대상: std_snp_data.ais_target (일별 파티션)
--- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증
--- ============================================================
-
--- ============================================================
--- 0. 대상 MMSI 임시 테이블 생성
--- ============================================================
-CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY);
-
--- psql에서 실행:
--- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt'
-
-
--- ============================================================
--- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부
--- ============================================================
-SELECT
- (SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi,
- COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d,
- (SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d,
- ROUND(COUNT(DISTINCT a.mmsi) * 100.0
- / NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
-WHERE a.message_timestamp >= NOW() - INTERVAL '2 days';
-
-
--- ============================================================
--- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치
--- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작)
--- ============================================================
-SELECT COUNT(*) AS cached_count,
- MIN(message_timestamp) AS oldest_cached,
- MAX(message_timestamp) AS newest_cached,
- NOW() - MAX(message_timestamp) AS newest_age
-FROM (
- SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp
- FROM std_snp_data.ais_target a
- JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
- WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
- ORDER BY a.mmsi, a.message_timestamp DESC
-) latest;
-
-
--- ============================================================
--- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건)
--- ============================================================
-SELECT DISTINCT ON (a.mmsi)
- a.mmsi,
- a.message_timestamp,
- a.name,
- a.vessel_type,
- a.lat,
- a.lon,
- a.sog,
- a.cog,
- a.heading,
- NOW() - a.message_timestamp AS data_age
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
-WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
-ORDER BY a.mmsi, a.message_timestamp DESC
-LIMIT 30;
-
-
--- ============================================================
--- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박)
--- ============================================================
-SELECT t.mmsi AS missing_mmsi
-FROM tmp_chn_mmsi t
-LEFT JOIN (
- SELECT DISTINCT mmsi
- FROM std_snp_data.ais_target
- WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi)
- AND message_timestamp >= NOW() - INTERVAL '2 days'
-) a ON t.mmsi = a.mmsi
-WHERE a.mmsi IS NULL
-ORDER BY t.mmsi;
-
-
--- ============================================================
--- 5. 시간대별 분포 (2일 기준 세부 확인)
--- ============================================================
-SELECT
- '6시간 이내' AS time_range,
- COUNT(DISTINCT mmsi) AS distinct_mmsi
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
-WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours'
-
-UNION ALL
-SELECT '12시간 이내', COUNT(DISTINCT mmsi)
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
-WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours'
-
-UNION ALL
-SELECT '1일 이내', COUNT(DISTINCT mmsi)
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
-WHERE a.message_timestamp >= NOW() - INTERVAL '1 day'
-
-UNION ALL
-SELECT '2일 이내', COUNT(DISTINCT mmsi)
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
-WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
-
-UNION ALL
-SELECT '전체(무제한)', COUNT(DISTINCT mmsi)
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi;
-
-
--- ============================================================
--- 6. 파티션별 대상 데이터 분포
--- ============================================================
-SELECT
- tableoid::regclass AS partition_name,
- COUNT(*) AS row_count,
- COUNT(DISTINCT mmsi) AS distinct_mmsi,
- MIN(message_timestamp) AS min_ts,
- MAX(message_timestamp) AS max_ts
-FROM std_snp_data.ais_target a
-JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
-GROUP BY tableoid::regclass
-ORDER BY max_ts DESC;
-
-
--- ============================================================
--- 7. 전체 ais_target 파티션 현황
--- ============================================================
-SELECT
- c.relname AS partition_name,
- pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
- s.n_live_tup AS estimated_rows
-FROM pg_inherits i
-JOIN pg_class c ON c.oid = i.inhrelid
-JOIN pg_stat_user_tables s ON s.relid = c.oid
-WHERE i.inhparent = 'std_snp_data.ais_target'::regclass
-ORDER BY c.relname DESC;
-
-
--- ============================================================
--- 정리
--- ============================================================
-DROP TABLE IF EXISTS tmp_chn_mmsi;
diff --git a/src/main/java/com/snp/batch/SnpBatchApplication.java b/src/main/java/com/snp/batch/SnpBatchApplication.java
index 8a395e9..b59535c 100644
--- a/src/main/java/com/snp/batch/SnpBatchApplication.java
+++ b/src/main/java/com/snp/batch/SnpBatchApplication.java
@@ -2,11 +2,10 @@ package com.snp.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
-@SpringBootApplication(exclude = KafkaAutoConfiguration.class)
+@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan
public class SnpBatchApplication {
diff --git a/src/main/java/com/snp/batch/global/cleanup/LogCleanupConfig.java b/src/main/java/com/snp/batch/global/cleanup/LogCleanupConfig.java
new file mode 100644
index 0000000..31561fb
--- /dev/null
+++ b/src/main/java/com/snp/batch/global/cleanup/LogCleanupConfig.java
@@ -0,0 +1,37 @@
+package com.snp.batch.global.cleanup;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 배치 로그 정리 설정
+ *
+ * 로그 종류별 보존 기간(일) 설정
+ *
+ * 설정 예시:
+ * app.batch.log-cleanup:
+ * api-log-retention-days: 30
+ * batch-meta-retention-days: 90
+ * failed-record-retention-days: 90
+ * recollection-history-retention-days: 90
+ */
+@Getter
+@Setter
+@Configuration
+@ConfigurationProperties(prefix = "app.batch.log-cleanup")
+public class LogCleanupConfig {
+
+ /** batch_api_log 보존 기간 (일) */
+ private int apiLogRetentionDays = 30;
+
+ /** Spring Batch 메타 테이블 보존 기간 (일) */
+ private int batchMetaRetentionDays = 90;
+
+ /** batch_failed_record (RESOLVED) 보존 기간 (일) */
+ private int failedRecordRetentionDays = 90;
+
+ /** batch_recollection_history 보존 기간 (일) */
+ private int recollectionHistoryRetentionDays = 90;
+}
diff --git a/src/main/java/com/snp/batch/global/cleanup/LogCleanupJobConfig.java b/src/main/java/com/snp/batch/global/cleanup/LogCleanupJobConfig.java
new file mode 100644
index 0000000..1081c8a
--- /dev/null
+++ b/src/main/java/com/snp/batch/global/cleanup/LogCleanupJobConfig.java
@@ -0,0 +1,69 @@
+package com.snp.batch.global.cleanup;
+
+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
+ *
+ * 스케줄: 매일 02:00 (0 0 2 * * ?)
+ *
+ * 동작:
+ * - 보존 기간이 지난 배치 로그 데이터를 삭제
+ * - batch_api_log (30일), Spring Batch 메타 (90일),
+ * batch_failed_record/RESOLVED (90일), batch_recollection_history (90일)
+ */
+@Slf4j
+@Configuration
+public class LogCleanupJobConfig {
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final LogCleanupTasklet logCleanupTasklet;
+
+ public LogCleanupJobConfig(
+ JobRepository jobRepository,
+ PlatformTransactionManager transactionManager,
+ LogCleanupTasklet logCleanupTasklet) {
+ this.jobRepository = jobRepository;
+ this.transactionManager = transactionManager;
+ this.logCleanupTasklet = logCleanupTasklet;
+ }
+
+ @Bean(name = "logCleanupStep")
+ public Step logCleanupStep() {
+ return new StepBuilder("logCleanupStep", jobRepository)
+ .tasklet(logCleanupTasklet, transactionManager)
+ .build();
+ }
+
+ @Bean(name = "LogCleanupJob")
+ public Job logCleanupJob() {
+ log.info("Job 생성: LogCleanupJob");
+
+ return new JobBuilder("LogCleanupJob", jobRepository)
+ .listener(new JobExecutionListener() {
+ @Override
+ public void beforeJob(JobExecution jobExecution) {
+ log.info("[LogCleanupJob] 배치 로그 정리 Job 시작");
+ }
+
+ @Override
+ public void afterJob(JobExecution jobExecution) {
+ log.info("[LogCleanupJob] 배치 로그 정리 Job 완료 - 상태: {}",
+ jobExecution.getStatus());
+ }
+ })
+ .start(logCleanupStep())
+ .build();
+ }
+}
diff --git a/src/main/java/com/snp/batch/global/cleanup/LogCleanupTasklet.java b/src/main/java/com/snp/batch/global/cleanup/LogCleanupTasklet.java
new file mode 100644
index 0000000..79f0da0
--- /dev/null
+++ b/src/main/java/com/snp/batch/global/cleanup/LogCleanupTasklet.java
@@ -0,0 +1,148 @@
+package com.snp.batch.global.cleanup;
+
+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.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class LogCleanupTasklet implements Tasklet {
+
+ private final JdbcTemplate jdbcTemplate;
+ private final LogCleanupConfig config;
+
+ @Value("${app.batch.target-schema.name}")
+ private String schema;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
+ log.info("========================================");
+ log.info("배치 로그 정리 Job 시작");
+ log.info("========================================");
+
+ int totalDeleted = 0;
+
+ // 1. batch_api_log 정리
+ totalDeleted += cleanupApiLog();
+
+ // 2. Spring Batch 메타 테이블 정리 (FK 순서)
+ totalDeleted += cleanupBatchMeta();
+
+ // 3. batch_failed_record 정리 (RESOLVED만)
+ totalDeleted += cleanupFailedRecord();
+
+ // 4. batch_recollection_history 정리
+ totalDeleted += cleanupRecollectionHistory();
+
+ log.info("========================================");
+ log.info("배치 로그 정리 Job 완료 - 총 삭제: {} 건", totalDeleted);
+ log.info("========================================");
+
+ return RepeatStatus.FINISHED;
+ }
+
+ private int cleanupApiLog() {
+ int days = config.getApiLogRetentionDays();
+ String sql = String.format(
+ "DELETE FROM %s.batch_api_log WHERE created_at < NOW() - INTERVAL '%d days'",
+ schema, days);
+ int deleted = jdbcTemplate.update(sql);
+ log.info("[batch_api_log] 보존기간: {}일, 삭제: {}건", days, deleted);
+ return deleted;
+ }
+
+ private int cleanupBatchMeta() {
+ int days = config.getBatchMetaRetentionDays();
+ int totalDeleted = 0;
+
+ // FK 의존 순서: step_execution_context → step_execution → job_execution_context → job_execution_params → job_execution → job_instance(orphan)
+
+ // 1. batch_step_execution_context
+ String sql1 = String.format(
+ "DELETE FROM %s.batch_step_execution_context WHERE step_execution_id IN (" +
+ "SELECT se.step_execution_id FROM %s.batch_step_execution se " +
+ "JOIN %s.batch_job_execution je ON se.job_execution_id = je.job_execution_id " +
+ "WHERE je.create_time < NOW() - INTERVAL '%d days')",
+ schema, schema, schema, days);
+ int deleted = jdbcTemplate.update(sql1);
+ totalDeleted += deleted;
+ log.info("[batch_step_execution_context] 삭제: {}건", deleted);
+
+ // 2. batch_step_execution
+ String sql2 = String.format(
+ "DELETE FROM %s.batch_step_execution WHERE job_execution_id IN (" +
+ "SELECT job_execution_id FROM %s.batch_job_execution " +
+ "WHERE create_time < NOW() - INTERVAL '%d days')",
+ schema, schema, days);
+ deleted = jdbcTemplate.update(sql2);
+ totalDeleted += deleted;
+ log.info("[batch_step_execution] 삭제: {}건", deleted);
+
+ // 3. batch_job_execution_context
+ String sql3 = String.format(
+ "DELETE FROM %s.batch_job_execution_context WHERE job_execution_id IN (" +
+ "SELECT job_execution_id FROM %s.batch_job_execution " +
+ "WHERE create_time < NOW() - INTERVAL '%d days')",
+ schema, schema, days);
+ deleted = jdbcTemplate.update(sql3);
+ totalDeleted += deleted;
+ log.info("[batch_job_execution_context] 삭제: {}건", deleted);
+
+ // 4. batch_job_execution_params
+ String sql4 = String.format(
+ "DELETE FROM %s.batch_job_execution_params WHERE job_execution_id IN (" +
+ "SELECT job_execution_id FROM %s.batch_job_execution " +
+ "WHERE create_time < NOW() - INTERVAL '%d days')",
+ schema, schema, days);
+ deleted = jdbcTemplate.update(sql4);
+ totalDeleted += deleted;
+ log.info("[batch_job_execution_params] 삭제: {}건", deleted);
+
+ // 5. batch_job_execution
+ String sql5 = String.format(
+ "DELETE FROM %s.batch_job_execution WHERE create_time < NOW() - INTERVAL '%d days'",
+ schema, days);
+ deleted = jdbcTemplate.update(sql5);
+ totalDeleted += deleted;
+ log.info("[batch_job_execution] 삭제: {}건", deleted);
+
+ // 6. batch_job_instance (참조 없는 인스턴스만)
+ String sql6 = String.format(
+ "DELETE FROM %s.batch_job_instance WHERE job_instance_id NOT IN (" +
+ "SELECT DISTINCT job_instance_id FROM %s.batch_job_execution)",
+ schema, schema);
+ deleted = jdbcTemplate.update(sql6);
+ totalDeleted += deleted;
+ log.info("[batch_job_instance] orphan 삭제: {}건", deleted);
+
+ log.info("[Spring Batch 메타] 보존기간: {}일, 총 삭제: {}건", days, totalDeleted);
+ return totalDeleted;
+ }
+
+ private int cleanupFailedRecord() {
+ int days = config.getFailedRecordRetentionDays();
+ String sql = String.format(
+ "DELETE FROM %s.batch_failed_record WHERE status = 'RESOLVED' AND resolved_at < NOW() - INTERVAL '%d days'",
+ schema, days);
+ int deleted = jdbcTemplate.update(sql);
+ log.info("[batch_failed_record] 보존기간: {}일 (RESOLVED만), 삭제: {}건", days, deleted);
+ return deleted;
+ }
+
+ private int cleanupRecollectionHistory() {
+ int days = config.getRecollectionHistoryRetentionDays();
+ String sql = String.format(
+ "DELETE FROM %s.batch_recollection_history WHERE created_at < NOW() - INTERVAL '%d days'",
+ schema, days);
+ int deleted = jdbcTemplate.update(sql);
+ log.info("[batch_recollection_history] 보존기간: {}일, 삭제: {}건", days, deleted);
+ return deleted;
+ }
+}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java
deleted file mode 100644
index 025b84c..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java
+++ /dev/null
@@ -1,143 +0,0 @@
-package com.snp.batch.jobs.aistarget.batch.config;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-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 {
-
- private final AisTargetDataProcessor aisTargetDataProcessor;
- private final AisTargetDataWriter aisTargetDataWriter;
- private final WebClient maritimeAisApiWebClient;
- private final ObjectMapper objectMapper;
- 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,
- ObjectMapper objectMapper,
- Core20CacheManager core20CacheManager) {
- super(jobRepository, transactionManager);
- this.aisTargetDataProcessor = aisTargetDataProcessor;
- this.aisTargetDataWriter = aisTargetDataWriter;
- this.maritimeAisApiWebClient = maritimeAisApiWebClient;
- this.objectMapper = objectMapper;
- this.core20CacheManager = core20CacheManager;
- }
-
- @Override
- protected String getJobName() {
- return "aisTargetImportJob";
- }
-
- @Override
- protected String getStepName() {
- return "aisTargetImportStep";
- }
-
- @Override
- protected ItemReader createReader() {
- return new AisTargetDataReader(maritimeAisApiWebClient, objectMapper, sinceSeconds);
- }
-
- @Override
- protected ItemProcessor createProcessor() {
- return aisTargetDataProcessor;
- }
-
- @Override
- protected ItemWriter 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 시작 (API → 캐시) - 수집 시점: {}", 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 완료 (API → 캐시) - 상태: {}, 캐시 적재 건수: {}, Core20 캐시 크기: {}",
- getJobName(),
- jobExecution.getStatus(),
- jobExecution.getStepExecutions().stream()
- .mapToLong(se -> se.getWriteCount())
- .sum(),
- core20CacheManager.size());
- }
- });
- }
-
- @Bean(name = "aisTargetImportJob")
- public Job aisTargetImportJob() {
- return job();
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetApiResponse.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetApiResponse.java
deleted file mode 100644
index 2c19f30..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetApiResponse.java
+++ /dev/null
@@ -1,27 +0,0 @@
-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 targetArr;
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetDto.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetDto.java
deleted file mode 100644
index 6ecca2b..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetDto.java
+++ /dev/null
@@ -1,167 +0,0 @@
-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;
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java
deleted file mode 100644
index 709254d..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java
+++ /dev/null
@@ -1,129 +0,0 @@
-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
- *
- * 테이블: std_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; // 배치 수집 시점
-
- // ========== 선종 분류 정보 ==========
- /**
- * MDA 범례코드 (signalKindCode)
- * - vesselType + extraInfo 기반으로 치환
- * - 예: "000020"(어선), "000023"(카고), "000027"(일반/기타)
- */
- private String signalKindCode;
-
- // ========== 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;
-
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/processor/AisTargetDataProcessor.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/processor/AisTargetDataProcessor.java
deleted file mode 100644
index 4c8913d..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/processor/AisTargetDataProcessor.java
+++ /dev/null
@@ -1,137 +0,0 @@
-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 {
-
- 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);
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/reader/AisTargetDataReader.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/reader/AisTargetDataReader.java
deleted file mode 100644
index 0056666..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/reader/AisTargetDataReader.java
+++ /dev/null
@@ -1,158 +0,0 @@
-package com.snp.batch.jobs.aistarget.batch.reader;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-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.core.io.buffer.DataBuffer;
-import org.springframework.core.io.buffer.DataBufferUtils;
-import org.springframework.web.reactive.function.client.WebClient;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardOpenOption;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-
-/**
- * AIS Target 데이터 Reader
- *
- * API: POST /AisSvc.svc/AIS/GetTargetsEnhanced
- * Request: {"sinceSeconds": "60"}
- *
- * 동작:
- * - 매 분 15초에 실행 (Quartz 스케줄)
- * - 최근 60초 동안의 전체 선박 위치 정보 조회
- * - 약 33,000건/분 처리
- * - 대용량 응답을 임시 파일로 스트리밍하여 메모리 버퍼 제한 우회
- */
-@Slf4j
-public class AisTargetDataReader extends BaseApiReader {
-
- private final int sinceSeconds;
- private final ObjectMapper objectMapper;
-
- public AisTargetDataReader(WebClient webClient, ObjectMapper objectMapper, int sinceSeconds) {
- super(webClient);
- this.objectMapper = objectMapper;
- 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 fetchDataFromApi() {
- Path tempFile = null;
- try {
- log.info("[{}] API 호출 시작 (스트리밍 모드): {} {}", getReaderName(), getHttpMethod(), getApiPath());
-
- tempFile = Files.createTempFile("ais-response-", ".json");
-
- // 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록
- long bytesWritten = streamResponseToFile(tempFile);
- log.info("[{}] 응답 스트리밍 완료: {} bytes → {}", getReaderName(), bytesWritten, tempFile.getFileName());
-
- // 임시 파일에서 JSON 파싱
- AisTargetApiResponse response = parseResponseFromFile(tempFile);
-
- if (response != null && response.getTargetArr() != null) {
- List 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);
- } finally {
- deleteTempFile(tempFile);
- }
- }
-
- /**
- * WebClient 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록한다.
- * bodyToMono()와 달리 메모리 버퍼 제한(maxInMemorySize)의 영향을 받지 않는다.
- */
- private long streamResponseToFile(Path tempFile) throws IOException {
- try (OutputStream os = Files.newOutputStream(tempFile, StandardOpenOption.WRITE)) {
- webClient.post()
- .uri(getApiPath())
- .bodyValue(getRequestBody())
- .retrieve()
- .bodyToFlux(DataBuffer.class)
- .doOnNext(dataBuffer -> {
- try {
- byte[] bytes = new byte[dataBuffer.readableByteCount()];
- dataBuffer.read(bytes);
- os.write(bytes);
- } catch (IOException e) {
- throw new RuntimeException("임시 파일 쓰기 실패", e);
- } finally {
- DataBufferUtils.release(dataBuffer);
- }
- })
- .blockLast();
- }
- return Files.size(tempFile);
- }
-
- private AisTargetApiResponse parseResponseFromFile(Path tempFile) throws IOException {
- try (InputStream is = Files.newInputStream(tempFile)) {
- return objectMapper.readValue(is, AisTargetApiResponse.class);
- }
- }
-
- private void deleteTempFile(Path tempFile) {
- if (tempFile != null) {
- try {
- Files.deleteIfExists(tempFile);
- } catch (IOException e) {
- log.warn("[{}] 임시 파일 삭제 실패: {}", getReaderName(), tempFile, e);
- }
- }
- }
-
- @Override
- protected void afterFetch(List data) {
- if (data != null && !data.isEmpty()) {
- log.info("[{}] 데이터 조회 완료 - 총 {} 건", getReaderName(), data.size());
- }
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java
deleted file mode 100644
index 20233b0..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java
+++ /dev/null
@@ -1,67 +0,0 @@
-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 findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp);
-
- /**
- * MMSI로 최신 위치 조회
- */
- Optional findLatestByMmsi(Long mmsi);
-
- /**
- * 여러 MMSI의 최신 위치 조회
- */
- List findLatestByMmsiIn(List mmsiList);
-
- /**
- * 여러 MMSI의 최신 위치 조회 (시간 범위 필터)
- *
- * @param mmsiList 대상 MMSI 목록
- * @param since 이 시점 이후 데이터만 조회
- */
- List findLatestByMmsiInSince(List mmsiList, OffsetDateTime since);
-
- /**
- * 시간 범위 내 특정 MMSI의 항적 조회
- */
- List findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end);
-
- /**
- * 시간 범위 + 공간 범위 내 선박 조회
- */
- List findByTimeRangeAndArea(
- OffsetDateTime start,
- OffsetDateTime end,
- Double centerLon,
- Double centerLat,
- Double radiusMeters
- );
-
- /**
- * 배치 INSERT (UPSERT)
- */
- void batchUpsert(List entities);
-
- /**
- * 전체 건수 조회
- */
- long count();
-
- /**
- * 오래된 데이터 삭제 (보존 기간 이전 데이터)
- */
- int deleteOlderThan(OffsetDateTime threshold);
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java
deleted file mode 100644
index 5f9a570..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java
+++ /dev/null
@@ -1,409 +0,0 @@
-package com.snp.batch.jobs.aistarget.batch.repository;
-
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-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 구현체
- *
- * 테이블: {targetSchema}.ais_target
- * PK: mmsi + message_timestamp (복합키)
- */
-@Slf4j
-@Repository
-public class AisTargetRepositoryImpl implements AisTargetRepository {
-
- private final JdbcTemplate jdbcTemplate;
- private final String tableName;
- private final String upsertSql;
-
- public AisTargetRepositoryImpl(JdbcTemplate jdbcTemplate,
- @Value("${app.batch.target-schema.name}") String targetSchema) {
- this.jdbcTemplate = jdbcTemplate;
- this.tableName = targetSchema + ".ais_target";
- this.upsertSql = buildUpsertSql(targetSchema);
- }
-
- private String buildUpsertSql(String schema) {
- return """
- INSERT INTO %s.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
- """.formatted(schema);
- }
-
-
- // ==================== RowMapper ====================
-
- private final RowMapper rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
- .mmsi(rs.getLong("mmsi"))
- .messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
- .imo(toLong(rs, "imo"))
- .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(toInt(rs, "rot"))
- .length(toInt(rs, "length"))
- .width(toInt(rs, "width"))
- .draught(rs.getObject("draught", Double.class))
- .lengthBow(toInt(rs, "length_bow"))
- .lengthStern(toInt(rs, "length_stern"))
- .widthPort(toInt(rs, "width_port"))
- .widthStarboard(toInt(rs, "width_starboard"))
- .destination(rs.getString("destination"))
- .eta(toOffsetDateTime(rs.getTimestamp("eta")))
- .status(rs.getString("status"))
- .ageMinutes(rs.getObject("age_minutes", Double.class))
- .positionAccuracy(toInt(rs, "position_accuracy"))
- .timestampUtc(toInt(rs, "timestamp_utc"))
- .repeatIndicator(toInt(rs, "repeat_indicator"))
- .raimFlag(toInt(rs, "raim_flag"))
- .radioStatus(toInt(rs, "radio_status"))
- .regional(toInt(rs, "regional"))
- .regional2(toInt(rs, "regional2"))
- .spare(toInt(rs, "spare"))
- .spare2(toInt(rs, "spare2"))
- .aisVersion(toInt(rs, "ais_version"))
- .positionFixType(toInt(rs, "position_fix_type"))
- .dte(toInt(rs, "dte"))
- .bandFlag(toInt(rs, "band_flag"))
- .receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
- .collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
- .tonnesCargo(toInt(rs, "tonnes_cargo"))
- .inSTS(toInt(rs, "in_sts"))
- .onBerth(rs.getObject("on_berth", Boolean.class))
- .dwt(toInt(rs, "dwt"))
- .anomalous(rs.getString("anomalous"))
- .destinationPortID(toInt(rs, "destination_port_id"))
- .destinationTidied(rs.getString("destination_tidied"))
- .destinationUNLOCODE(rs.getString("destination_unlocode"))
- .imoVerified(rs.getString("imo_verified"))
- .lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
- .lpcCode(toInt(rs, "lpc_code"))
- .messageType(toInt(rs, "message_type"))
- .source(rs.getString("source"))
- .stationId(rs.getString("station_id"))
- .zoneId(rs.getObject("zone_id", Double.class))
- .build();
-
- // ==================== Repository Methods ====================
-
- @Override
- public Optional findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
- String sql = "SELECT * FROM " + tableName + " WHERE mmsi = ? AND message_timestamp = ?";
- List results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
- return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
- }
-
- @Override
- public Optional findLatestByMmsi(Long mmsi) {
- String sql = """
- SELECT * FROM %s
- WHERE mmsi = ?
- ORDER BY message_timestamp DESC
- LIMIT 1
- """.formatted(tableName);
- List results = jdbcTemplate.query(sql, rowMapper, mmsi);
- return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
- }
-
- @Override
- public List findLatestByMmsiIn(List 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(tableName);
-
- Long[] mmsiArray = mmsiList.toArray(new Long[0]);
- return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
- }
-
- @Override
- public List findLatestByMmsiInSince(List mmsiList, OffsetDateTime since) {
- if (mmsiList == null || mmsiList.isEmpty()) {
- return List.of();
- }
-
- String sql = """
- SELECT DISTINCT ON (mmsi) *
- FROM %s
- WHERE mmsi = ANY(?)
- AND message_timestamp >= ?
- ORDER BY mmsi, message_timestamp DESC
- """.formatted(tableName);
-
- Long[] mmsiArray = mmsiList.toArray(new Long[0]);
- return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since));
- }
-
- @Override
- public List 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(tableName);
- return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
- }
-
- @Override
- public List 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(tableName);
-
- return jdbcTemplate.query(sql, rowMapper,
- toTimestamp(start), toTimestamp(end),
- centerLon, centerLat, radiusMeters);
- }
-
- @Override
- @Transactional
- public void batchUpsert(List entities) {
- if (entities == null || entities.isEmpty()) {
- return;
- }
-
- log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
-
- jdbcTemplate.batchUpdate(upsertSql, 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 " + tableName;
- Long count = jdbcTemplate.queryForObject(sql, Long.class);
- return count != null ? count : 0L;
- }
-
- @Override
- @Transactional
- public int deleteOlderThan(OffsetDateTime threshold) {
- String sql = "DELETE FROM " + tableName + " WHERE message_timestamp < ?";
- int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
- log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
- return deleted;
- }
-
- // ==================== Helper Methods ====================
-
- /**
- * int8(bigint) → Integer 안전 변환
- * PostgreSQL JDBC 드라이버는 int8 → Integer 자동 변환을 지원하지 않아
- * getObject("col", Integer.class) 사용 시 오류 발생. Number로 읽어서 변환.
- */
- private Integer toInt(ResultSet rs, String column) throws SQLException {
- Object val = rs.getObject(column);
- if (val == null) return null;
- return ((Number) val).intValue();
- }
-
- private Long toLong(ResultSet rs, String column) throws SQLException {
- Object val = rs.getObject(column);
- if (val == null) return null;
- return ((Number) val).longValue();
- }
-
- 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;
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java
deleted file mode 100644
index d0e1d2b..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java
+++ /dev/null
@@ -1,90 +0,0 @@
-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.chnprmship.ChnPrmShipCacheManager;
-import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
-import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
-import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.lang.Nullable;
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-
-/**
- * AIS Target 데이터 Writer (캐시 전용)
- *
- * 동작:
- * 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
- * 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
- * 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함)
- * 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
- * 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만)
- *
- * 참고:
- * - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
- * - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속
- * - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵
- */
-@Slf4j
-@Component
-public class AisTargetDataWriter extends BaseWriter {
-
- private final AisTargetCacheManager cacheManager;
- private final AisClassTypeClassifier classTypeClassifier;
- @Nullable
- private final AisTargetKafkaProducer kafkaProducer;
- private final ChnPrmShipCacheManager chnPrmShipCacheManager;
-
- public AisTargetDataWriter(
- AisTargetCacheManager cacheManager,
- AisClassTypeClassifier classTypeClassifier,
- @Nullable AisTargetKafkaProducer kafkaProducer,
- ChnPrmShipCacheManager chnPrmShipCacheManager) {
- super("AisTarget");
- this.cacheManager = cacheManager;
- this.classTypeClassifier = classTypeClassifier;
- this.kafkaProducer = kafkaProducer;
- this.chnPrmShipCacheManager = chnPrmShipCacheManager;
- }
-
- @Override
- protected void writeItems(List items) throws Exception {
- log.info("AIS Target 캐시 업데이트 시작: {} 건", items.size());
-
- // 1. ClassType 분류 (캐시 저장 전에 분류)
- // - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
- classTypeClassifier.classifyAll(items);
-
- // 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
- items.forEach(item -> {
- SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
- item.setSignalKindCode(kindCode.getCode());
- });
-
- // 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함)
- cacheManager.putAll(items);
-
- log.info("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
- items.size(), cacheManager.size());
-
- // 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
- chnPrmShipCacheManager.putIfTarget(items);
-
- // 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만)
- if (kafkaProducer == null) {
- log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵");
- return;
- }
-
- AisTargetKafkaProducer.PublishSummary summary = kafkaProducer.publish(items);
- log.info("AIS Kafka 전송 완료 - topic: {}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
- kafkaProducer.getTopic(),
- summary.getRequestedCount(),
- summary.getSuccessCount(),
- summary.getFailedCount(),
- summary.getSkippedCount());
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java
deleted file mode 100644
index fb6e22b..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java
+++ /dev/null
@@ -1,272 +0,0 @@
-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 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 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 getAll(List mmsiList) {
- if (mmsiList == null || mmsiList.isEmpty()) {
- return Collections.emptyMap();
- }
-
- // Caffeine의 getAllPresent는 존재하는 키만 반환
- Map result = cache.getAllPresent(mmsiList);
-
- log.debug("캐시 배치 조회 - 요청: {}, 히트: {}",
- mmsiList.size(), result.size());
-
- return result;
- }
-
- /**
- * 여러 데이터 일괄 저장/업데이트 (배치 Writer에서 호출)
- *
- * @param entities AIS Target 엔티티 목록
- */
- public void putAll(List 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 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 getStats() {
- CacheStats stats = cache.stats();
-
- Map 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 getAllValues() {
- return cache.asMap().values();
- }
-
- /**
- * 시간 범위 내 데이터 필터링
- *
- * @param minutes 최근 N분
- * @return 시간 범위 내 엔티티 목록
- */
- public List 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);
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java
deleted file mode 100644
index c884954..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java
+++ /dev/null
@@ -1,229 +0,0 @@
-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 filter(List entities, AisTargetFilterRequest request) {
- if (entities == null || entities.isEmpty()) {
- return List.of();
- }
-
- if (!request.hasAnyFilter()) {
- return entities;
- }
-
- long startTime = System.currentTimeMillis();
-
- List 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 filterByClassType(List entities, AisTargetSearchRequest request) {
- if (entities == null || entities.isEmpty()) {
- return Collections.emptyList();
- }
-
- if (!request.hasClassTypeFilter()) {
- return entities;
- }
-
- long startTime = System.currentTimeMillis();
-
- List 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);
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java
deleted file mode 100644
index b2c2fce..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java
+++ /dev/null
@@ -1,317 +0,0 @@
-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 filterByCircle(
- Collection entities,
- double centerLon,
- double centerLat,
- double radiusMeters) {
-
- if (entities == null || entities.isEmpty()) {
- return new ArrayList<>();
- }
-
- long startTime = System.currentTimeMillis();
-
- // 병렬 스트림으로 필터링 (대용량 데이터 최적화)
- List 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 filterByCircleWithDistance(
- Collection 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 filterByPolygon(
- Collection 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 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 filterByWkt(
- Collection 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 filterByGeoJson(
- Collection 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;
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java
deleted file mode 100644
index 52b6e3e..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java
+++ /dev/null
@@ -1,131 +0,0 @@
-package com.snp.batch.jobs.aistarget.chnprmship;
-
-import com.github.benmanes.caffeine.cache.Cache;
-import com.github.benmanes.caffeine.cache.Caffeine;
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-import jakarta.annotation.PostConstruct;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
-
-import java.time.OffsetDateTime;
-import java.time.ZoneOffset;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
-
-/**
- * 중국 허가선박 전용 캐시
- *
- * - 대상 MMSI(~1,400척)만 별도 관리
- * - TTL: expireAfterWrite (마지막 put 이후 N일 경과 시 만료)
- * - 순수 캐시 조회 전용 (DB fallback 없음)
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class ChnPrmShipCacheManager {
-
- private final ChnPrmShipProperties properties;
- private Cache cache;
-
- @PostConstruct
- public void init() {
- this.cache = Caffeine.newBuilder()
- .maximumSize(properties.getMaxSize())
- .expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS)
- .recordStats()
- .build();
-
- log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}",
- properties.getTtlDays(), properties.getMaxSize());
- }
-
- /**
- * 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장
- *
- * @param items 전체 AIS Target 데이터 (배치 수집 결과)
- * @return 저장된 건수
- */
- public int putIfTarget(List items) {
- if (items == null || items.isEmpty()) {
- return 0;
- }
-
- int updated = 0;
- for (AisTargetEntity item : items) {
- if (!properties.isTarget(item.getMmsi())) {
- continue;
- }
-
- AisTargetEntity existing = cache.getIfPresent(item.getMmsi());
- if (existing == null || isNewerOrEqual(item, existing)) {
- cache.put(item.getMmsi(), item);
- updated++;
- }
- }
-
- if (updated > 0) {
- log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}",
- items.size(), updated, cache.estimatedSize());
- }
- return updated;
- }
-
- /**
- * 시간 범위 내 캐시 데이터 조회
- *
- * @param minutes 조회 범위 (분)
- * @return 시간 범위 내 데이터 목록
- */
- public List getByTimeRange(int minutes) {
- OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes);
-
- return cache.asMap().values().stream()
- .filter(entity -> entity.getMessageTimestamp() != null)
- .filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
- .collect(Collectors.toList());
- }
-
- /**
- * 워밍업용 직접 저장 (시간 비교 없이 저장)
- */
- public void putAll(List entities) {
- if (entities == null || entities.isEmpty()) {
- return;
- }
- for (AisTargetEntity entity : entities) {
- if (entity != null && entity.getMmsi() != null) {
- cache.put(entity.getMmsi(), entity);
- }
- }
- }
-
- public long size() {
- return cache.estimatedSize();
- }
-
- public Map getStats() {
- var stats = cache.stats();
- return Map.of(
- "estimatedSize", cache.estimatedSize(),
- "maxSize", properties.getMaxSize(),
- "ttlDays", properties.getTtlDays(),
- "targetMmsiCount", properties.getMmsiSet().size(),
- "hitCount", stats.hitCount(),
- "missCount", stats.missCount(),
- "hitRate", String.format("%.2f%%", stats.hitRate() * 100)
- );
- }
-
- private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) {
- if (candidate.getMessageTimestamp() == null) {
- return false;
- }
- if (existing.getMessageTimestamp() == null) {
- return true;
- }
- return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp());
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheWarmer.java b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheWarmer.java
deleted file mode 100644
index 2155237..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheWarmer.java
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.snp.batch.jobs.aistarget.chnprmship;
-
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
-import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.boot.ApplicationRunner;
-import org.springframework.stereotype.Component;
-
-import java.time.OffsetDateTime;
-import java.time.ZoneOffset;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * 기동 시 ChnPrmShip 캐시 워밍업
- *
- * DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다.
- * 이후 매 분 배치 수집에서 실시간 데이터가 캐시를 갱신한다.
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class ChnPrmShipCacheWarmer implements ApplicationRunner {
-
- private static final int DB_QUERY_CHUNK_SIZE = 500;
-
- private final ChnPrmShipProperties properties;
- private final ChnPrmShipCacheManager cacheManager;
- private final AisTargetRepository aisTargetRepository;
-
- @Override
- public void run(ApplicationArguments args) {
- if (!properties.isWarmupEnabled()) {
- log.info("ChnPrmShip 캐시 워밍업 비활성화");
- return;
- }
-
- if (properties.getMmsiSet().isEmpty()) {
- log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다");
- return;
- }
-
- OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC)
- .minusDays(properties.getWarmupDays());
-
- log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})",
- properties.getMmsiSet().size(), properties.getWarmupDays(), since);
- long startTime = System.currentTimeMillis();
-
- List mmsiList = new ArrayList<>(properties.getMmsiSet());
- int totalLoaded = 0;
-
- for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) {
- List chunk = mmsiList.subList(i,
- Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
-
- List fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since);
-
- // signalKindCode 치환 (DB 데이터는 치환이 안 되어 있을 수 있음)
- fromDb.forEach(entity -> {
- if (entity.getSignalKindCode() == null) {
- SignalKindCode kindCode = SignalKindCode.resolve(
- entity.getVesselType(), entity.getExtraInfo());
- entity.setSignalKindCode(kindCode.getCode());
- }
- });
-
- cacheManager.putAll(fromDb);
- totalLoaded += fromDb.size();
- }
-
- long elapsed = System.currentTimeMillis() - startTime;
- log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms",
- properties.getMmsiSet().size(), totalLoaded, elapsed);
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipProperties.java b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipProperties.java
deleted file mode 100644
index b66b0ed..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipProperties.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.snp.batch.jobs.aistarget.chnprmship;
-
-import jakarta.annotation.PostConstruct;
-import lombok.Getter;
-import lombok.Setter;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.core.io.DefaultResourceLoader;
-import org.springframework.core.io.Resource;
-
-import java.io.BufferedReader;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-import java.util.Collections;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-/**
- * 중국 허가선박(ChnPrmShip) 설정
- *
- * 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다.
- */
-@Slf4j
-@Getter
-@Setter
-@ConfigurationProperties(prefix = "app.batch.chnprmship")
-public class ChnPrmShipProperties {
-
- /**
- * MMSI 목록 리소스 경로
- */
- private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt";
-
- /**
- * 캐시 TTL (일)
- * - 마지막 put() 이후 이 기간이 지나면 만료
- */
- private int ttlDays = 2;
-
- /**
- * 최대 캐시 크기
- */
- private int maxSize = 2000;
-
- /**
- * 기동 시 DB 워밍업 활성화 여부
- */
- private boolean warmupEnabled = true;
-
- /**
- * DB 워밍업 조회 범위 (일)
- */
- private int warmupDays = 2;
-
- /**
- * 로딩된 대상 MMSI 집합
- */
- private Set mmsiSet = Collections.emptySet();
-
- @PostConstruct
- public void init() {
- try {
- Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath);
- try (BufferedReader reader = new BufferedReader(
- new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
- mmsiSet = reader.lines()
- .map(String::trim)
- .filter(line -> !line.isEmpty() && !line.startsWith("#"))
- .map(Long::parseLong)
- .collect(Collectors.toUnmodifiableSet());
- }
- log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath);
- } catch (Exception e) {
- log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage());
- mmsiSet = Collections.emptySet();
- }
- }
-
- public boolean isTarget(Long mmsi) {
- return mmsi != null && mmsiSet.contains(mmsi);
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java
deleted file mode 100644
index e8672ce..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java
+++ /dev/null
@@ -1,160 +0,0 @@
-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 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 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();
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java
deleted file mode 100644
index 0a63ae8..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java
+++ /dev/null
@@ -1,219 +0,0 @@
-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 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 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 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 getStats() {
- Map 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;
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java
deleted file mode 100644
index fc3f06e..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java
+++ /dev/null
@@ -1,71 +0,0 @@
-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: std_snp_svc.tb_ship_main_info (imo_no, mmsi_no)
- * - prod: std_snp_svc.tb_ship_main_info (imo_no, mmsi_no)
- */
-@Slf4j
-@Getter
-@Setter
-@ConfigurationProperties(prefix = "app.batch.core20")
-public class Core20Properties {
-
- /**
- * 스키마명 (예: std_snp_svc)
- */
- private String schema = "std_snp_svc";
-
- /**
- * 테이블명 (예: tb_ship_main_info)
- */
- private String table = "tb_ship_main_info";
-
- /**
- * IMO/LRNO 컬럼명 (PK, NOT NULL)
- */
- private String imoColumn = "imo_no";
-
- /**
- * MMSI 컬럼명 (NULLABLE)
- */
- private String mmsiColumn = "mmsi_no";
-
- /**
- * 전체 테이블명 반환 (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);
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/SignalKindCode.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/SignalKindCode.java
deleted file mode 100644
index de6b375..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/classifier/SignalKindCode.java
+++ /dev/null
@@ -1,118 +0,0 @@
-package com.snp.batch.jobs.aistarget.classifier;
-
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-
-/**
- * MDA 선종 범례코드
- *
- * GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로
- * MDA 범례코드(signalKindCode)로 치환한다.
- *
- * @see 치환 규칙표
- */
-@Getter
-@RequiredArgsConstructor
-public enum SignalKindCode {
-
- FISHING("000020", "어선"),
- KCGV("000021", "함정"),
- FERRY("000022", "여객선"),
- CARGO("000023", "카고"),
- TANKER("000024", "탱커"),
- GOV("000025", "관공선"),
- DEFAULT("000027", "일반/기타선박"),
- BUOY("000028", "부이/항로표지");
-
- private final String code;
- private final String koreanName;
-
- /**
- * GlobalAIS vesselType + extraInfo → MDA 범례코드 치환
- *
- * 치환 우선순위:
- * 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN 등)
- * 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing 등)
- * 3. fallback → DEFAULT (000027)
- */
- public static SignalKindCode resolve(String vesselType, String extraInfo) {
- String vt = normalizeOrEmpty(vesselType);
- String ei = normalizeOrEmpty(extraInfo);
-
- // 1. vesselType 단독 매칭 (extraInfo 무관)
- switch (vt) {
- case "cargo":
- return CARGO;
- case "tanker":
- return TANKER;
- case "passenger":
- return FERRY;
- case "aton":
- return BUOY;
- case "law enforcement":
- return GOV;
- case "search and rescue":
- return KCGV;
- case "local vessel":
- return FISHING;
- default:
- break;
- }
-
- // vesselType 그룹 매칭 (복합 선종명)
- if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
- return GOV;
- }
- if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
- return FERRY;
- }
-
- // 2. "Vessel" + extraInfo 조합
- if ("vessel".equals(vt)) {
- return resolveVesselExtraInfo(ei);
- }
-
- // 3. "N/A" + extraInfo 조합
- if ("n/a".equals(vt)) {
- if (ei.startsWith("hazardous cat")) {
- return CARGO;
- }
- return DEFAULT;
- }
-
- // 4. fallback
- return DEFAULT;
- }
-
- private static SignalKindCode resolveVesselExtraInfo(String extraInfo) {
- if ("fishing".equals(extraInfo)) {
- return FISHING;
- }
- if ("military operations".equals(extraInfo)) {
- return GOV;
- }
- if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
- return GOV;
- }
- if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
- return FISHING;
- }
- if (extraInfo.startsWith("hazardous cat")) {
- return CARGO;
- }
- return DEFAULT;
- }
-
- private static boolean matchesAny(String value, String... candidates) {
- for (String candidate : candidates) {
- if (candidate.equals(value)) {
- return true;
- }
- }
- return false;
- }
-
- private static String normalizeOrEmpty(String value) {
- return (value == null || value.isBlank()) ? "" : value.strip().toLowerCase();
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaConfig.java b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaConfig.java
deleted file mode 100644
index 542397f..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaConfig.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.snp.batch.jobs.aistarget.kafka;
-
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Import;
-
-/**
- * Kafka 조건부 활성화 설정
- *
- * SnpBatchApplication에서 KafkaAutoConfiguration을 기본 제외한 뒤,
- * app.batch.ais-target.kafka.enabled=true인 경우에만 재활성화한다.
- *
- * enabled=false(기본값)이면 KafkaTemplate 등 Kafka 관련 빈이 전혀 생성되지 않는다.
- */
-@Configuration
-@ConditionalOnProperty(
- name = "app.batch.ais-target.kafka.enabled",
- havingValue = "true"
-)
-@Import(KafkaAutoConfiguration.class)
-public class AisTargetKafkaConfig {
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaMessage.java b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaMessage.java
deleted file mode 100644
index fa064a7..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaMessage.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.snp.batch.jobs.aistarget.kafka;
-
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.OffsetDateTime;
-import java.time.ZoneOffset;
-
-/**
- * AIS Target Kafka 메시지 스키마
- */
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-@JsonInclude(JsonInclude.Include.NON_NULL)
-public class AisTargetKafkaMessage {
-
- /**
- * 이벤트 고유 식별자
- * - 형식: {mmsi}_{messageTimestamp}
- */
- private String eventId;
-
- /**
- * Kafka key와 동일한 선박 식별자
- */
- private String key;
-
- /**
- * Kafka 발행 시각(UTC)
- */
- private OffsetDateTime publishedAt;
-
- /**
- * AIS 원본/가공 데이터 전체 필드
- */
- private AisTargetEntity payload;
-
- public static AisTargetKafkaMessage from(AisTargetEntity entity) {
- String key = entity.getMmsi() != null ? String.valueOf(entity.getMmsi()) : null;
- String messageTs = entity.getMessageTimestamp() != null ? entity.getMessageTimestamp().toString() : "null";
-
- return AisTargetKafkaMessage.builder()
- .eventId(key + "_" + messageTs)
- .key(key)
- .publishedAt(OffsetDateTime.now(ZoneOffset.UTC))
- .payload(entity)
- .build();
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java
deleted file mode 100644
index f9f0728..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java
+++ /dev/null
@@ -1,211 +0,0 @@
-package com.snp.batch.jobs.aistarget.kafka;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.kafka.core.KafkaTemplate;
-import org.springframework.stereotype.Component;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.atomic.AtomicInteger;
-
-/**
- * AIS Target Kafka Producer
- *
- * 정책:
- * - key: MMSI
- * - value: AisTargetKafkaMessage(JSON)
- * - 실패 시 기본적으로 로그만 남기고 계속 진행 (failOnSendError=false)
- *
- * app.batch.ais-target.kafka.enabled=true인 경우에만 빈으로 등록된다.
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-@ConditionalOnProperty(name = "app.batch.ais-target.kafka.enabled", havingValue = "true")
-public class AisTargetKafkaProducer {
-
- private final KafkaTemplate kafkaTemplate;
- private final ObjectMapper objectMapper;
- private final AisTargetKafkaProperties kafkaProperties;
-
- public boolean isEnabled() {
- return kafkaProperties.isEnabled();
- }
-
- public String getTopic() {
- return kafkaProperties.getTopic();
- }
-
- /**
- * 수집 청크 데이터를 Kafka 전송용 서브청크로 분할해 전송
- */
- public PublishSummary publish(List entities) {
- if (!isEnabled()) {
- return PublishSummary.disabled();
- }
-
- if (entities == null || entities.isEmpty()) {
- return PublishSummary.empty();
- }
-
- int subChunkSize = Math.max(1, kafkaProperties.getSendChunkSize());
- PublishSummary totalSummary = PublishSummary.empty();
-
- for (int from = 0; from < entities.size(); from += subChunkSize) {
- int to = Math.min(from + subChunkSize, entities.size());
- List subChunk = entities.subList(from, to);
-
- PublishSummary chunkSummary = publishSubChunk(subChunk);
- totalSummary.merge(chunkSummary);
-
- log.info("AIS Kafka 서브청크 전송 완료 - topic: {}, 범위: {}~{}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
- getTopic(), from, to - 1,
- chunkSummary.getRequestedCount(),
- chunkSummary.getSuccessCount(),
- chunkSummary.getFailedCount(),
- chunkSummary.getSkippedCount());
- }
-
- if (kafkaProperties.isFailOnSendError() && totalSummary.getFailedCount() > 0) {
- throw new IllegalStateException("AIS Kafka 전송 실패 건수: " + totalSummary.getFailedCount());
- }
-
- return totalSummary;
- }
-
- private PublishSummary publishSubChunk(List subChunk) {
- AtomicInteger successCount = new AtomicInteger(0);
- AtomicInteger failedCount = new AtomicInteger(0);
- AtomicInteger skippedCount = new AtomicInteger(0);
- AtomicInteger sampledErrorLogs = new AtomicInteger(0);
- List> futures = new ArrayList<>(subChunk.size());
-
- for (AisTargetEntity entity : subChunk) {
- if (!isValid(entity)) {
- skippedCount.incrementAndGet();
- continue;
- }
-
- try {
- String key = String.valueOf(entity.getMmsi());
- String payload = objectMapper.writeValueAsString(AisTargetKafkaMessage.from(entity));
-
- CompletableFuture trackedFuture = kafkaTemplate.send(getTopic(), key, payload)
- .handle((result, ex) -> {
- if (ex != null) {
- failedCount.incrementAndGet();
- logSendError(sampledErrorLogs,
- "AIS Kafka 전송 실패 - topic: " + getTopic()
- + ", key: " + key
- + ", messageTimestamp: " + entity.getMessageTimestamp()
- + ", error: " + ex.getMessage());
- } else {
- successCount.incrementAndGet();
- }
- return null;
- });
-
- futures.add(trackedFuture);
-
- } catch (JsonProcessingException e) {
- failedCount.incrementAndGet();
- logSendError(sampledErrorLogs,
- "AIS Kafka 메시지 직렬화 실패 - mmsi: " + entity.getMmsi()
- + ", messageTimestamp: " + entity.getMessageTimestamp()
- + ", error: " + e.getMessage());
- } catch (Exception e) {
- failedCount.incrementAndGet();
- logSendError(sampledErrorLogs,
- "AIS Kafka 전송 요청 실패 - mmsi: " + entity.getMmsi()
- + ", messageTimestamp: " + entity.getMessageTimestamp()
- + ", error: " + e.getMessage());
- }
- }
-
- if (!futures.isEmpty()) {
- CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
- kafkaTemplate.flush();
- }
-
- return PublishSummary.of(
- false,
- subChunk.size(),
- successCount.get(),
- failedCount.get(),
- skippedCount.get()
- );
- }
-
- private boolean isValid(AisTargetEntity entity) {
- return entity != null
- && entity.getMmsi() != null
- && entity.getMessageTimestamp() != null;
- }
-
- private void logSendError(AtomicInteger sampledErrorLogs, String message) {
- int current = sampledErrorLogs.incrementAndGet();
- if (current <= 5) {
- log.error(message);
- return;
- }
-
- if (current == 6) {
- log.error("AIS Kafka 전송 오류 로그가 많아 이후 상세 로그는 생략합니다.");
- }
- }
-
- @Getter
- public static class PublishSummary {
- private final boolean disabled;
- private int requestedCount;
- private int successCount;
- private int failedCount;
- private int skippedCount;
-
- private PublishSummary(
- boolean disabled,
- int requestedCount,
- int successCount,
- int failedCount,
- int skippedCount
- ) {
- this.disabled = disabled;
- this.requestedCount = requestedCount;
- this.successCount = successCount;
- this.failedCount = failedCount;
- this.skippedCount = skippedCount;
- }
-
- public static PublishSummary disabled() {
- return of(true, 0, 0, 0, 0);
- }
-
- public static PublishSummary empty() {
- return of(false, 0, 0, 0, 0);
- }
-
- public static PublishSummary of(
- boolean disabled,
- int requestedCount,
- int successCount,
- int failedCount,
- int skippedCount
- ) {
- return new PublishSummary(disabled, requestedCount, successCount, failedCount, skippedCount);
- }
-
- public void merge(PublishSummary other) {
- this.requestedCount += other.requestedCount;
- this.successCount += other.successCount;
- this.failedCount += other.failedCount;
- this.skippedCount += other.skippedCount;
- }
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProperties.java b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProperties.java
deleted file mode 100644
index a49e0ab..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProperties.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.snp.batch.jobs.aistarget.kafka;
-
-import lombok.Getter;
-import lombok.Setter;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-
-/**
- * AIS Target Kafka 전송 설정
- */
-@Getter
-@Setter
-@ConfigurationProperties(prefix = "app.batch.ais-target.kafka")
-public class AisTargetKafkaProperties {
-
- /**
- * Kafka 전송 활성화 여부
- */
- private boolean enabled = true;
-
- /**
- * 전송 대상 토픽
- */
- private String topic = "tp_SNP_AIS_Signal";
-
- /**
- * Kafka 전송 서브청크 크기
- * 수집 청크(예: 5만)와 별도로 전송 배치를 분할한다.
- */
- private int sendChunkSize = 5000;
-
- /**
- * 전송 실패 시 Step 실패 여부
- * false면 실패 로그만 남기고 다음 처리를 계속한다.
- */
- private boolean failOnSendError = false;
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java
deleted file mode 100644
index 05bf36a..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java
+++ /dev/null
@@ -1,500 +0,0 @@
-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.media.Schema;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.ResponseEntity;
-import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.*;
-
-import jakarta.validation.Valid;
-import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.NotNull;
-import java.util.List;
-import java.util.Map;
-
-/**
- * AIS Target REST API Controller
- *
- * 캐시 우선 조회 전략:
- * - 캐시에서 먼저 조회
- * - 캐시 미스 시 DB 조회 후 캐시 업데이트
- */
-@Slf4j
-@Validated
-@RestController
-@RequestMapping("/api/ais-target")
-@RequiredArgsConstructor
-@Tag(name = "AIS Target", description = "AIS 선박 위치 정보 API")
-public class AisTargetController {
-
- private final AisTargetService aisTargetService;
-
- // ==================== 중국 허가선박 전용 ====================
-
- @Operation(
- summary = "중국 허가선박 위치 조회",
- description = """
- 중국 허가 어선(~1,400척) 전용 캐시에서 위치 정보를 조회합니다.
-
- - 순수 캐시 조회 (DB fallback 없음)
- - 캐시에 없으면 빈 배열 반환
- - 응답 구조는 /search와 동일
- """
- )
- @GetMapping("/chnprmship")
- public ResponseEntity>> getChnPrmShip(
- @Parameter(description = "조회 범위 (분, 기본: 2880 = 2일)", example = "2880")
- @RequestParam(defaultValue = "2880") Integer minutes) {
-
- log.info("ChnPrmShip 조회 요청 - minutes: {}", minutes);
-
- List result = aisTargetService.findChnPrmShip(minutes);
- return ResponseEntity.ok(ApiResponse.success(
- "ChnPrmShip 조회 완료: " + result.size() + " 건",
- result
- ));
- }
-
- @Operation(
- summary = "중국 허가선박 캐시 통계",
- description = "중국 허가선박 전용 캐시의 현재 상태를 조회합니다"
- )
- @GetMapping("/chnprmship/stats")
- public ResponseEntity>> getChnPrmShipStats() {
- Map stats = aisTargetService.getChnPrmShipCacheStats();
- return ResponseEntity.ok(ApiResponse.success(stats));
- }
-
- // ==================== 단건 조회 ====================
-
- @Operation(
- summary = "MMSI로 최신 위치 조회",
- description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)",
- responses = {
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 MMSI의 위치 정보 없음")
- }
- )
- @GetMapping("/{mmsi}")
- public ResponseEntity> 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>> getLatestByMmsiList(
- @Parameter(description = "MMSI 번호 목록", required = true)
- @RequestBody List mmsiList) {
- log.info("다건 최신 위치 조회 요청 - 요청 수: {}", mmsiList.size());
-
- List 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>> search(
- @Parameter(description = "조회 범위 (분)", required = true, example = "5")
- @RequestParam @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") 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 result = aisTargetService.search(request);
- return ResponseEntity.ok(ApiResponse.success(
- "검색 완료: " + result.size() + " 건",
- result
- ));
- }
-
- @Operation(
- summary = "시간/공간 범위로 선박 검색 (POST)",
- responses = {
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (minutes 누락 또는 1 미만)")
- },
- 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>> searchPost(
- @Valid @RequestBody AisTargetSearchRequest request) {
- log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}",
- request.getMinutes(), request.hasAreaFilter(), request.getClassType());
-
- List result = aisTargetService.search(request);
- return ResponseEntity.ok(ApiResponse.success(
- "검색 완료: " + result.size() + " 건",
- result
- ));
- }
-
- // ==================== 조건 필터 검색 ====================
-
- @Operation(
- summary = "항해 조건 필터 검색",
- responses = {
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "필터 검색 성공"),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패")
- },
- 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": ["Under way using engine", "At anchor", "Moored"]
- }
- ```
-
- ---
- ## 항행상태 값 (statusList)
-
- statusList에는 **텍스트 문자열**을 전달해야 합니다 (대소문자 무시).
-
- | 값 | 설명 |
- |------|------|
- | Under way using engine | 기관 사용 항해 중 |
- | At anchor | 정박 중 |
- | Not under command | 조종불능 |
- | Restricted manoeuverability | 조종제한 |
- | Constrained by her draught | 흘수제약 |
- | Moored | 계류 중 |
- | Aground | 좌초 |
- | Engaged in Fishing | 어로 중 |
- | Under way sailing | 돛 항해 중 |
- | Power Driven Towing Astern | 예인선 (후방) |
- | Power Driven Towing Alongside | 예인선 (측방) |
- | AIS Sart | 비상위치지시기 |
- | N/A | 정보없음 |
-
- ---
- **참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함).
- """
- )
- @PostMapping("/search/filter")
- public ResponseEntity>> 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 result = aisTargetService.searchByFilter(request);
- return ResponseEntity.ok(ApiResponse.success(
- "필터 검색 완료: " + result.size() + " 건",
- result
- ));
- }
-
- // ==================== 폴리곤 검색 ====================
-
- @Operation(
- summary = "폴리곤 범위 내 선박 검색",
- responses = {
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (coordinates 또는 minutes 누락)")
- },
- 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>> searchByPolygon(
- @Valid @RequestBody PolygonSearchRequest request) {
- log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
- request.getMinutes(), request.getCoordinates().length);
-
- List result = aisTargetService.searchByPolygon(
- request.getMinutes(),
- request.getCoordinates()
- );
- return ResponseEntity.ok(ApiResponse.success(
- "폴리곤 검색 완료: " + result.size() + " 건",
- result
- ));
- }
-
- @Operation(
- summary = "WKT 범위 내 선박 검색",
- responses = {
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (wkt 또는 minutes 누락)")
- },
- 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>> searchByWkt(
- @Valid @RequestBody WktSearchRequest request) {
- log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
-
- List 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>> 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 result =
- aisTargetService.searchWithDistance(minutes, centerLon, centerLat, radiusMeters);
- return ResponseEntity.ok(ApiResponse.success(
- "거리 포함 검색 완료: " + result.size() + " 건",
- result
- ));
- }
-
- // ==================== 항적 조회 ====================
-
- @Operation(
- summary = "항적 조회",
- description = "특정 MMSI의 시간 범위 내 항적 (위치 이력)을 조회합니다"
- )
- @GetMapping("/{mmsi}/track")
- public ResponseEntity>> 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 track = aisTargetService.getTrack(mmsi, minutes);
- return ResponseEntity.ok(ApiResponse.success(
- "항적 조회 완료: " + track.size() + " 포인트",
- track
- ));
- }
-
- // ==================== 캐시 관리 ====================
-
- @Operation(
- summary = "캐시 통계 조회",
- description = "AIS Target 캐시의 현재 상태를 조회합니다"
- )
- @GetMapping("/cache/stats")
- public ResponseEntity>> getCacheStats() {
- Map stats = aisTargetService.getCacheStats();
- return ResponseEntity.ok(ApiResponse.success(stats));
- }
-
- @Operation(
- summary = "캐시 초기화",
- description = "AIS Target 캐시를 초기화합니다"
- )
- @DeleteMapping("/cache")
- public ResponseEntity> clearCache() {
- log.warn("캐시 초기화 요청");
- aisTargetService.clearCache();
- return ResponseEntity.ok(ApiResponse.success("캐시가 초기화되었습니다", null));
- }
-
- // ==================== 요청 DTO (내부 클래스) ====================
-
- /**
- * 폴리곤 검색 요청 DTO
- */
- @lombok.Data
- @Schema(description = "폴리곤 범위 검색 요청")
- public static class PolygonSearchRequest {
- @NotNull(message = "minutes는 필수입니다")
- @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
- @Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
- private Integer minutes;
-
- @NotNull(message = "coordinates는 필수입니다")
- @Schema(description = "폴리곤 좌표 [[경도, 위도], ...] (닫힌 형태: 첫점=끝점)",
- example = "[[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]",
- requiredMode = Schema.RequiredMode.REQUIRED)
- private double[][] coordinates;
- }
-
- /**
- * WKT 검색 요청 DTO
- */
- @lombok.Data
- @Schema(description = "WKT 범위 검색 요청")
- public static class WktSearchRequest {
- @NotNull(message = "minutes는 필수입니다")
- @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
- @Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
- private Integer minutes;
-
- @NotNull(message = "wkt는 필수입니다")
- @Schema(description = "WKT 문자열 (POLYGON, MULTIPOLYGON 지원)",
- example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))",
- requiredMode = Schema.RequiredMode.REQUIRED)
- private String wkt;
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java
deleted file mode 100644
index 66637d8..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java
+++ /dev/null
@@ -1,165 +0,0 @@
-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 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();
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java
deleted file mode 100644
index 3c7eb44..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java
+++ /dev/null
@@ -1,157 +0,0 @@
-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 {
-
- // 선박 식별 정보
- @Schema(description = "MMSI (Maritime Mobile Service Identity) 번호", example = "440123456")
- private Long mmsi;
-
- @Schema(description = "IMO 번호 (0인 경우 미등록)", example = "9137960")
- private Long imo;
-
- @Schema(description = "선박명", example = "ROYAUME DES OCEANS")
- private String name;
-
- @Schema(description = "호출 부호", example = "4SFTEST")
- private String callsign;
-
- @Schema(description = "선박 유형 (외부 API 원본 텍스트)", example = "Vessel")
- private String vesselType;
-
- // 위치 정보
- @Schema(description = "위도 (WGS84)", example = "35.0796")
- private Double lat;
-
- @Schema(description = "경도 (WGS84)", example = "129.0756")
- private Double lon;
-
- // 항해 정보
- @Schema(description = "선수방위 (degrees, 0-360)", example = "36.0")
- private Double heading;
-
- @Schema(description = "대지속력 (knots)", example = "12.5")
- private Double sog;
-
- @Schema(description = "대지침로 (degrees, 0-360)", example = "36.2")
- private Double cog;
-
- @Schema(description = "회전율 (Rate of Turn)", example = "0")
- private Integer rot;
-
- // 선박 제원
- @Schema(description = "선박 길이 (미터)", example = "19")
- private Integer length;
-
- @Schema(description = "선박 폭 (미터)", example = "15")
- private Integer width;
-
- @Schema(description = "흘수 (미터)", example = "5.5")
- private Double draught;
-
- // 목적지 정보
- @Schema(description = "목적지", example = "BUSAN")
- private String destination;
-
- @Schema(description = "예정 도착 시간 (UTC)")
- private OffsetDateTime eta;
-
- @Schema(description = "항행상태 (텍스트)", example = "Under way using engine")
- private String status;
-
- // 타임스탬프
- @Schema(description = "AIS 메시지 발생 시각 (UTC)")
- private OffsetDateTime messageTimestamp;
-
- @Schema(description = "데이터 수신 시각 (UTC)")
- private OffsetDateTime receivedDate;
-
- // 데이터 소스 (캐시/DB)
- @Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
- private String source;
-
- // 선종 분류 정보
- @Schema(description = """
- MDA 범례코드 (선종 분류)
- - 000020: 어선 (FISHING)
- - 000021: 함정 (KCGV)
- - 000022: 여객선 (FERRY)
- - 000023: 카고 (CARGO)
- - 000024: 탱커 (TANKER)
- - 000025: 관공선 (GOV)
- - 000027: 일반/기타선박 (DEFAULT)
- - 000028: 부이/항로표지 (BUOY)
- """,
- example = "000023")
- private String signalKindCode;
-
- // 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)
- .signalKindCode(entity.getSignalKindCode())
- .classType(entity.getClassType())
- .core20Mmsi(entity.getCore20Mmsi())
- .build();
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java
deleted file mode 100644
index 33d334f..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java
+++ /dev/null
@@ -1,66 +0,0 @@
-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"));
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/NumericCondition.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/NumericCondition.java
deleted file mode 100644
index cca3f54..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/NumericCondition.java
+++ /dev/null
@@ -1,90 +0,0 @@
-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 ?";
- };
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java
deleted file mode 100644
index 045f20d..0000000
--- a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java
+++ /dev/null
@@ -1,422 +0,0 @@
-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.chnprmship.ChnPrmShipCacheManager;
-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 final ChnPrmShipCacheManager chnPrmShipCacheManager;
-
- private static final String SOURCE_CACHE = "cache";
- private static final String SOURCE_DB = "db";
-
- // ==================== 단건 조회 ====================
-
- /**
- * MMSI로 최신 위치 조회 (캐시 우선)
- */
- public Optional findLatestByMmsi(Long mmsi) {
- log.debug("최신 위치 조회 - MMSI: {}", mmsi);
-
- // 1. 캐시 조회
- Optional 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 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 findLatestByMmsiList(List mmsiList) {
- if (mmsiList == null || mmsiList.isEmpty()) {
- return Collections.emptyList();
- }
-
- log.debug("다건 최신 위치 조회 - 요청: {} 건", mmsiList.size());
-
- List result = new ArrayList<>();
-
- // 1. 캐시에서 조회
- Map cachedData = cacheManager.getAll(mmsiList);
- for (AisTargetEntity entity : cachedData.values()) {
- result.add(AisTargetResponseDto.from(entity, SOURCE_CACHE));
- }
-
- // 2. 캐시 미스 목록
- List missedMmsiList = mmsiList.stream()
- .filter(mmsi -> !cachedData.containsKey(mmsi))
- .collect(Collectors.toList());
-
- // 3. DB에서 캐시 미스 데이터 조회
- if (!missedMmsiList.isEmpty()) {
- log.debug("캐시 미스 DB 조회 - {} 건", missedMmsiList.size());
- List 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 search(AisTargetSearchRequest request) {
- log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}",
- request.getMinutes(), request.hasAreaFilter(), request.getClassType());
-
- long startTime = System.currentTimeMillis();
-
- // 1. 캐시에서 시간 범위 내 데이터 조회
- List 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 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 searchByFilter(AisTargetFilterRequest request) {
- log.debug("필터 검색 - minutes: {}, hasFilter: {}",
- request.getMinutes(), request.hasAnyFilter());
-
- long startTime = System.currentTimeMillis();
-
- // 1. 캐시에서 시간 범위 내 데이터 조회
- List 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 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 searchByPolygon(int minutes, double[][] polygonCoordinates) {
- log.debug("폴리곤 검색 - minutes: {}, points: {}", minutes, polygonCoordinates.length);
-
- long startTime = System.currentTimeMillis();
-
- // 1. 캐시에서 시간 범위 내 데이터 조회
- List 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 searchByWkt(int minutes, String wkt) {
- log.debug("WKT 검색 - minutes: {}, wkt: {}", minutes, wkt);
-
- long startTime = System.currentTimeMillis();
-
- // 1. 캐시에서 시간 범위 내 데이터 조회
- List 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 searchWithDistance(
- int minutes, double centerLon, double centerLat, double radiusMeters) {
-
- log.debug("거리 포함 검색 - minutes: {}, center: ({}, {}), radius: {}",
- minutes, centerLon, centerLat, radiusMeters);
-
- // 1. 캐시에서 시간 범위 내 데이터 조회
- List entities = cacheManager.getByTimeRange(minutes);
-
- // 2. 거리 포함 필터링
- List 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 getTrack(Long mmsi, Integer minutes) {
- log.debug("항적 조회 - MMSI: {}, 범위: {}분", mmsi, minutes);
-
- OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
- OffsetDateTime start = now.minusMinutes(minutes);
-
- List 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());
- }
-
- // ==================== 중국 허가선박 전용 조회 ====================
-
- /**
- * 중국 허가선박 전용 캐시 조회 (DB fallback 없음)
- *
- * @param minutes 조회 범위 (분)
- * @return 시간 범위 내 대상 선박 목록
- */
- public List findChnPrmShip(int minutes) {
- log.debug("ChnPrmShip 조회 - minutes: {}", minutes);
-
- long startTime = System.currentTimeMillis();
-
- List entities = chnPrmShipCacheManager.getByTimeRange(minutes);
-
- long elapsed = System.currentTimeMillis() - startTime;
- log.info("ChnPrmShip 조회 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
-
- return entities.stream()
- .map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
- .collect(Collectors.toList());
- }
-
- /**
- * ChnPrmShip 캐시 통계 조회
- */
- public Map getChnPrmShipCacheStats() {
- return chnPrmShipCacheManager.getStats();
- }
-
- // ==================== 캐시 관리 ====================
-
- /**
- * 캐시 통계 조회
- */
- public Map 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;
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/config/AisTargetDbSyncJobConfig.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/config/AisTargetDbSyncJobConfig.java
deleted file mode 100644
index bd32662..0000000
--- a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/config/AisTargetDbSyncJobConfig.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package com.snp.batch.jobs.aistargetdbsync.batch.config;
-
-import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.AisTargetDbSyncTasklet;
-import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.ShipLastPositionSyncTasklet;
-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: 없음 (캐시 기반)
- *
- * 동작:
- * - Step 1: Caffeine 캐시에서 최근 15분 이내 데이터 조회 → ais_target DB UPSERT
- * - Step 2: 캐시에서 IMO별 최신 위치 조회 → tb_ship_main_info 위치 컬럼 UPDATE
- *
- * 데이터 흐름:
- * - aisTargetImportJob (1분): API → 캐시 업데이트
- * - aisTargetDbSyncJob (15분): 캐시 → DB 저장 (이 Job)
- * - Step 1: 캐시 → ais_target UPSERT
- * - Step 2: 캐시 → tb_ship_main_info UPDATE (IMO별 최신 1건)
- */
-@Slf4j
-@Configuration
-public class AisTargetDbSyncJobConfig {
-
- private final JobRepository jobRepository;
- private final PlatformTransactionManager transactionManager;
- private final AisTargetDbSyncTasklet aisTargetDbSyncTasklet;
- private final ShipLastPositionSyncTasklet shipLastPositionSyncTasklet;
-
- public AisTargetDbSyncJobConfig(
- JobRepository jobRepository,
- PlatformTransactionManager transactionManager,
- AisTargetDbSyncTasklet aisTargetDbSyncTasklet,
- ShipLastPositionSyncTasklet shipLastPositionSyncTasklet) {
- this.jobRepository = jobRepository;
- this.transactionManager = transactionManager;
- this.aisTargetDbSyncTasklet = aisTargetDbSyncTasklet;
- this.shipLastPositionSyncTasklet = shipLastPositionSyncTasklet;
- }
-
- @Bean(name = "aisTargetDbSyncStep")
- public Step aisTargetDbSyncStep() {
- return new StepBuilder("aisTargetDbSyncStep", jobRepository)
- .tasklet(aisTargetDbSyncTasklet, transactionManager)
- .build();
- }
-
- @Bean(name = "shipLastPositionSyncStep")
- public Step shipLastPositionSyncStep() {
- return new StepBuilder("shipLastPositionSyncStep", jobRepository)
- .tasklet(shipLastPositionSyncTasklet, 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())
- .next(shipLastPositionSyncStep())
- .build();
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/repository/ShipLastPositionSyncRepository.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/repository/ShipLastPositionSyncRepository.java
deleted file mode 100644
index f94a549..0000000
--- a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/repository/ShipLastPositionSyncRepository.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.snp.batch.jobs.aistargetdbsync.batch.repository;
-
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-
-import java.util.List;
-
-/**
- * 캐시 기반 Ship Last Position 동기화 Repository
- *
- * AisTargetEntity(캐시 데이터)를 받아 tb_ship_main_info의 위치/항해 컬럼을 UPDATE
- */
-public interface ShipLastPositionSyncRepository {
-
- int updateLastPositions(List entities);
- void updateLastPositionsTemp(List entities);
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/repository/ShipLastPositionSyncRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/repository/ShipLastPositionSyncRepositoryImpl.java
deleted file mode 100644
index c7adba8..0000000
--- a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/repository/ShipLastPositionSyncRepositoryImpl.java
+++ /dev/null
@@ -1,185 +0,0 @@
-package com.snp.batch.jobs.aistargetdbsync.batch.repository;
-
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.stereotype.Repository;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.sql.PreparedStatement;
-import java.sql.Types;
-import java.util.List;
-
-/**
- * 캐시 기반 Ship Last Position 동기화 Repository 구현
- *
- * AisTargetEntity의 필드를 tb_ship_main_info 컬럼에 매핑하여 UPDATE 수행.
- * 기존 ShipLastPositionRepositoryImpl과 동일한 SQL을 사용하되,
- * AisTargetEntity의 타입(OffsetDateTime, Integer 등)에 맞게 변환 처리.
- */
-@Slf4j
-@Repository("shipLastPositionSyncRepository")
-public class ShipLastPositionSyncRepositoryImpl implements ShipLastPositionSyncRepository {
-
- private static final int BATCH_SIZE = 1000;
-
- private final JdbcTemplate jdbcTemplate;
-
- @Value("${app.batch.service-schema.name}")
- private String targetSchema;
-
- @Value("${app.batch.service-schema.tables.service-001}")
- private String tableName;
-
- public ShipLastPositionSyncRepositoryImpl(JdbcTemplate jdbcTemplate) {
- this.jdbcTemplate = jdbcTemplate;
- }
-
- private String getTableName() {
- return targetSchema + "." + tableName;
- }
-
- private String getUpdateSql() {
- return """
- UPDATE %s
- SET last_cptr_hr_utc = ?::timestamptz,
- last_port = ?,
- now_position_lat = ?,
- now_position_lon = ?,
- ship_dest = ?,
- arvl_prnmnt_hr = ?::timestamptz,
- bow_drctn = ?,
- cog = ?,
- sog = ?,
- ship_nav_status = ?,
- cargo_ton = ?,
- add_info = ?,
- sts_yn = ?,
- ancrg_yn = ?,
- mmsi_no = ?
- WHERE imo_no = ?
- """.formatted(getTableName());
- }
-
- private String getCoreUpdateSql() {
- return """
- UPDATE new_snp.core20
- SET lastseen = ?::timestamptz,
- lastport = ?,
- position_latitude = ?,
- position_longitude = ?,
- destination = ?,
- eta = ?::timestamptz,
- heading = ?,
- cog = ?,
- speedservice = ?,
- navstat = ?,
- tonnes_cargo = ?,
- extra_info = ?,
- in_sts = ?,
- on_berth = ?,
- mmsi = ?
- WHERE lrno = ?;
- """;
- }
-
- @Override
- @Transactional
- public int updateLastPositions(List entities) {
- if (entities == null || entities.isEmpty()) {
- return 0;
- }
-
- String sql = getUpdateSql();
- int totalUpdated = 0;
-
- int[][] batchResults = jdbcTemplate.batchUpdate(sql, entities, BATCH_SIZE,
- (ps, entity) -> setUpdateParameters(ps, entity));
-
- for (int[] batchResult : batchResults) {
- for (int result : batchResult) {
- if (result > 0) {
- totalUpdated += result;
- }
- }
- }
-
- log.info("Ship Last Position 동기화 완료 ({}): 대상={} 건, 갱신={} 건", tableName, entities.size(), totalUpdated);
- return totalUpdated;
- }
-
- @Override
- @Transactional
- public void updateLastPositionsTemp(List entities) {
-
- String sql = getCoreUpdateSql();
- int totalUpdated = 0;
-
- int[][] batchResults = jdbcTemplate.batchUpdate(sql, entities, BATCH_SIZE,
- (ps, entity) -> setUpdateParameters(ps, entity));
-
- for (int[] batchResult : batchResults) {
- for (int result : batchResult) {
- if (result > 0) {
- totalUpdated += result;
- }
- }
- }
- log.info("Ship Last Position 동기화 완료 (Core20): 대상={} 건, 갱신={} 건", entities.size(), totalUpdated);
- }
-
- private void setUpdateParameters(PreparedStatement ps, AisTargetEntity entity) throws java.sql.SQLException {
- int idx = 1;
-
- // last_cptr_hr_utc ← receivedDate (OffsetDateTime → String for ::timestamptz)
- ps.setString(idx++, entity.getReceivedDate() != null ? entity.getReceivedDate().toString() : null);
-
- // last_port ← lpcCode (Integer → String)
- ps.setString(idx++, entity.getLpcCode() != null ? String.valueOf(entity.getLpcCode()) : null);
-
- // now_position_lat ← lat (Double, null-safe)
- ps.setObject(idx++, entity.getLat(), Types.DOUBLE);
-
- // now_position_lon ← lon (Double, null-safe)
- ps.setObject(idx++, entity.getLon(), Types.DOUBLE);
-
- // ship_dest ← destination (String)
- ps.setString(idx++, entity.getDestination());
-
- // arvl_prnmnt_hr ← eta (OffsetDateTime → String for ::timestamptz)
- ps.setString(idx++, entity.getEta() != null ? entity.getEta().toString() : null);
-
- // bow_drctn ← heading (Double, null-safe)
- ps.setObject(idx++, entity.getHeading(), Types.DOUBLE);
-
- // cog ← cog (Double, null-safe)
- ps.setObject(idx++, entity.getCog(), Types.DOUBLE);
-
- // sog ← sog (Double, null-safe)
- ps.setObject(idx++, entity.getSog(), Types.DOUBLE);
-
- // ship_nav_status ← status (String)
- ps.setString(idx++, entity.getStatus());
-
- // cargo_ton ← tonnesCargo (Integer, null-safe)
- ps.setObject(idx++, entity.getTonnesCargo(), Types.INTEGER);
-
- // add_info ← extraInfo (String)
- ps.setString(idx++, entity.getExtraInfo());
-
- // sts_yn (tb_ship_main_info) / in_sts (core20) ← inSTS (Integer, null-safe)
- ps.setObject(idx++, entity.getInSTS(), Types.INTEGER);
-
- // ancrg_yn ← onBerth (Boolean, null-safe)
- ps.setObject(idx++, entity.getOnBerth(), Types.BOOLEAN);
-
- // mmsi_no ← mmsi (Long → 9자리 zero-padded String)
- ps.setString(idx++, entity.getMmsi() != null
- ? String.format("%09d", entity.getMmsi()) : null);
-
- // WHERE imo_no ← imoVerified (String)
- ps.setString(idx++, entity.getImoVerified());
-
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java
deleted file mode 100644
index 113e675..0000000
--- a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java
+++ /dev/null
@@ -1,121 +0,0 @@
-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.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.time.Instant;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicReference;
-
-/**
- * AIS Target DB Sync Tasklet
- *
- * 동작:
- * - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 데이터를 조회
- * - MMSI별 최신 위치 1건씩 DB에 UPSERT
- * - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
- *
- * 시간 범위 결정 전략:
- * - 첫 실행 또는 마지막 실행 정보 없음 → fallback(time-range-minutes) 사용
- * - 이후 실행 → 마지막 성공 시각 기준으로 경과 시간 자동 계산
- * - cron 주기를 변경해도 별도 설정 불필요 (자동 동기화)
- *
- * 참고:
- * - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
- * - 기존 aisTargetImportJob은 캐시 업데이트만 수행
- */
-@Slf4j
-@Component
-public class AisTargetDbSyncTasklet implements Tasklet {
-
- private final AisTargetCacheManager cacheManager;
- private final AisTargetRepository aisTargetRepository;
- private final int fallbackMinutes;
-
- /**
- * 마지막 성공 시각 (JVM 내 유지, 재기동 시 fallback 사용)
- */
- private final AtomicReference lastSuccessTime = new AtomicReference<>();
-
- public AisTargetDbSyncTasklet(
- AisTargetCacheManager cacheManager,
- AisTargetRepository aisTargetRepository,
- @Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
- this.cacheManager = cacheManager;
- this.aisTargetRepository = aisTargetRepository;
- this.fallbackMinutes = fallbackMinutes;
- }
-
- @Override
- public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
- Instant now = Instant.now();
- int rangeMinutes = resolveRangeMinutes(now);
-
- log.info("========================================");
- log.info("AIS Target DB Sync 시작");
- log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
- lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
- log.info("현재 캐시 크기: {}", cacheManager.size());
- log.info("========================================");
-
- long startTime = System.currentTimeMillis();
-
- // 1. 캐시에서 시간 범위 내 데이터 조회
- List entities = cacheManager.getByTimeRange(rangeMinutes);
-
- if (entities.isEmpty()) {
- log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
- lastSuccessTime.set(now);
- return RepeatStatus.FINISHED;
- }
-
- log.info("캐시에서 {} 건 조회 완료", entities.size());
-
- // 2. DB에 UPSERT
- aisTargetRepository.batchUpsert(entities);
-
- long elapsed = System.currentTimeMillis() - startTime;
-
- // 성공 시각 기록
- lastSuccessTime.set(now);
-
- 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;
- }
-
- private static final int MAX_RANGE_MINUTES = 60;
-
- /**
- * 조회 범위(분) 결정
- * - 마지막 성공 시각이 있으면: 경과 시간 + 1분 버퍼 (최대 60분)
- * - 없으면: fallback 값 사용
- * - 오래 중단 후 재가동 시에도 최대 60분으로 제한하여 과부하 방지
- */
- private int resolveRangeMinutes(Instant now) {
- Instant last = lastSuccessTime.get();
- if (last == null) {
- return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
- }
-
- long elapsedMinutes = java.time.Duration.between(last, now).toMinutes();
- // 경과 시간 + 1분 버퍼 (겹침 허용, UPSERT이므로 중복 안전), 최대 60분
- int range = (int) Math.max(elapsedMinutes + 1, 1);
- return Math.min(range, MAX_RANGE_MINUTES);
- }
-}
diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/ShipLastPositionSyncTasklet.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/ShipLastPositionSyncTasklet.java
deleted file mode 100644
index 3182da5..0000000
--- a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/ShipLastPositionSyncTasklet.java
+++ /dev/null
@@ -1,134 +0,0 @@
-package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
-
-import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
-import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
-import com.snp.batch.jobs.aistargetdbsync.batch.repository.ShipLastPositionSyncRepository;
-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.time.Duration;
-import java.time.Instant;
-import java.util.Comparator;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.stream.Collectors;
-
-/**
- * Ship Last Position Sync Tasklet
- *
- * 동작:
- * - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 AIS 데이터를 조회
- * - imoVerified가 유효한 항목만 필터링
- * - IMO별 messageTimestamp 최신 1건만 선택
- * - tb_ship_main_info의 위치/항해 14개 컬럼을 UPDATE
- *
- * 시간 범위 결정 전략:
- * - AisTargetDbSyncTasklet과 동일 (lastSuccessTime 기반, fallback 사용)
- */
-@Slf4j
-@Component
-public class ShipLastPositionSyncTasklet implements Tasklet {
-
- private final AisTargetCacheManager cacheManager;
- private final ShipLastPositionSyncRepository repository;
- private final int fallbackMinutes;
-
- private final AtomicReference lastSuccessTime = new AtomicReference<>();
-
- public ShipLastPositionSyncTasklet(
- AisTargetCacheManager cacheManager,
- ShipLastPositionSyncRepository repository,
- @Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
- this.cacheManager = cacheManager;
- this.repository = repository;
- this.fallbackMinutes = fallbackMinutes;
- }
-
- @Override
- public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
- Instant now = Instant.now();
- int rangeMinutes = resolveRangeMinutes(now);
-
- log.info("========================================");
- log.info("Ship Last Position Sync 시작");
- log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
- lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
- log.info("========================================");
-
- long startTime = System.currentTimeMillis();
-
- // 1. 캐시에서 시간 범위 내 데이터 조회
- List entities = cacheManager.getByTimeRange(rangeMinutes);
-
- if (entities.isEmpty()) {
- log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
- lastSuccessTime.set(now);
- return RepeatStatus.FINISHED;
- }
-
- // 2. imoVerified가 유효한 항목만 필터
- List imoFiltered = entities.stream()
- .filter(e -> isValidImo(e.getImoVerified()))
- .toList();
-
- if (imoFiltered.isEmpty()) {
- log.info("유효한 IMO가 있는 데이터가 없습니다 (전체: {} 건)", entities.size());
- lastSuccessTime.set(now);
- return RepeatStatus.FINISHED;
- }
-
- // 3. IMO별 messageTimestamp 최신 1건만 선택
- List latestPerImo = imoFiltered.stream()
- .collect(Collectors.groupingBy(AisTargetEntity::getImoVerified))
- .values().stream()
- .map(group -> group.stream()
- .max(Comparator.comparing(
- AisTargetEntity::getMessageTimestamp,
- Comparator.nullsFirst(Comparator.naturalOrder())))
- .orElseThrow())
- .toList();
-
- log.info("캐시 조회: {} 건 → IMO 유효: {} 건 → IMO별 최신: {} 건",
- entities.size(), imoFiltered.size(), latestPerImo.size());
-
- // 4. DB UPDATE
- int updated = repository.updateLastPositions(latestPerImo);
- repository.updateLastPositionsTemp(latestPerImo); // 임시 추가
-
- long elapsed = System.currentTimeMillis() - startTime;
-
- lastSuccessTime.set(now);
-
- log.info("========================================");
- log.info("Ship Last Position Sync 완료");
- log.info("대상: {} 건, 갱신: {} 건", latestPerImo.size(), updated);
- log.info("소요 시간: {}ms", elapsed);
- log.info("========================================");
-
- contribution.incrementWriteCount(updated);
-
- return RepeatStatus.FINISHED;
- }
-
- private static final int MAX_RANGE_MINUTES = 60;
-
- private int resolveRangeMinutes(Instant now) {
- Instant last = lastSuccessTime.get();
- if (last == null) {
- return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
- }
-
- long elapsedMinutes = Duration.between(last, now).toMinutes();
- int range = (int) Math.max(elapsedMinutes + 1, 1);
- return Math.min(range, MAX_RANGE_MINUTES);
- }
-
- private boolean isValidImo(String imoVerified) {
- return imoVerified != null && !imoVerified.isBlank() && !"0".equals(imoVerified);
- }
-}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index c1d67eb..2b55b9e 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -53,22 +53,6 @@ spring:
org.quartz.jobStore.isClustered: false
org.quartz.jobStore.misfireThreshold: 60000
- # Kafka Configuration (DEV)
- kafka:
- bootstrap-servers: localhost:9092 # TODO: DEV Kafka Broker IP/PORT 설정
- producer:
- key-serializer: org.apache.kafka.common.serialization.StringSerializer
- value-serializer: org.apache.kafka.common.serialization.StringSerializer
- acks: all
- retries: 3
- properties:
- enable.idempotence: true
- compression.type: snappy
- linger.ms: 20
- batch.size: 65536
- max.block.ms: 3000
- request.timeout.ms: 5000
- delivery.timeout.ms: 10000
# Server Configuration
server:
@@ -95,12 +79,6 @@ logging:
app:
batch:
chunk-size: 1000
- ship-api:
- url: https://shipsapi.maritime.spglobal.com
- ais-api:
- url: https://aisapi.maritime.spglobal.com
- webservice-api:
- url: https://webservices.maritime.spglobal.com
schedule:
enabled: true
cron: "0 0 * * * ?" # Every hour
@@ -116,48 +94,11 @@ app:
max-retry-count: 3
partition-count: 4 # dev: 2개 파티션
- # AIS Target 배치 설정
- ais-target:
- since-seconds: 60 # API 조회 범위 (초)
- chunk-size: 50000 # 배치 청크 크기
- schedule:
- cron: "15 * * * * ?" # 매 분 15초 실행
- kafka:
- enabled: false
- topic: tp_Global_AIS_Signal
- send-chunk-size: 5000
- fail-on-send-error: false
- # 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: std_snp_svc # 스키마명
- table: tb_ship_main_info # 테이블명
- imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
- mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
-
# 파티션 관리 설정
partition:
- # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
- daily-tables:
- - schema: std_snp_data
- table-name: ais_target
- partition-column: message_timestamp
- periods-ahead: 3 # 미리 생성할 일수
- # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
- monthly-tables: [] # 현재 없음
- # 기본 보관기간
+ daily-tables: []
+ monthly-tables: []
retention:
- daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
- monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
- # 개별 테이블 보관기간 설정 (옵션)
- custom:
- # - table-name: ais_target
- # retention-days: 30 # ais_target만 30일 보관
+ daily-default-days: 14
+ monthly-default-months: 1
+ custom: []
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index ed49c09..0a3c8e9 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -53,22 +53,6 @@ spring:
org.quartz.jobStore.isClustered: false
org.quartz.jobStore.misfireThreshold: 60000
- # Kafka Configuration (PROD)
- kafka:
- bootstrap-servers: 10.188.141.104:9092,10.188.141.105:9092,10.188.141.102:9092
- producer:
- key-serializer: org.apache.kafka.common.serialization.StringSerializer
- value-serializer: org.apache.kafka.common.serialization.StringSerializer
- acks: all
- retries: 3
- properties:
- enable.idempotence: true
- compression.type: snappy
- linger.ms: 20
- batch.size: 65536
- max.block.ms: 3000
- request.timeout.ms: 5000
- delivery.timeout.ms: 10000
# Server Configuration
server:
@@ -96,12 +80,6 @@ logging:
app:
batch:
chunk-size: 1000
- ship-api:
- url: http://10.29.16.219:18030/shipsapi
- ais-api:
- url: http://10.29.16.219:18030/aisapi
- webservice-api:
- url: http://10.29.16.219:18030/webservices
schedule:
enabled: true
cron: "0 0 * * * ?" # Every hour
@@ -117,48 +95,11 @@ app:
max-retry-count: 3 # 최대 재시도 횟수
partition-count: 4 # prod: 4개 파티션
- # AIS Target 배치 설정
- ais-target:
- since-seconds: 60 # API 조회 범위 (초)
- chunk-size: 50000 # 배치 청크 크기
- schedule:
- cron: "15 * * * * ?" # 매 분 15초 실행
- kafka:
- enabled: true
- topic: tp_Global_AIS_Signal
- send-chunk-size: 5000
- fail-on-send-error: false
- # 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: std_snp_svc # 스키마명
- table: tb_ship_main_info # 테이블명
- imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
- mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
-
# 파티션 관리 설정
partition:
- # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
- daily-tables:
- - schema: std_snp_data
- table-name: ais_target
- partition-column: message_timestamp
- periods-ahead: 3 # 미리 생성할 일수
- # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
- monthly-tables: [] # 현재 없음
- # 기본 보관기간
+ daily-tables: []
+ monthly-tables: []
retention:
- daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
- monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
- # 개별 테이블 보관기간 설정 (옵션)
- custom:
- # - table-name: ais_target
- # retention-days: 30 # ais_target만 30일 보관
+ daily-default-days: 14
+ monthly-default-months: 1
+ custom: []
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 49d7c50..29200fa 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -53,22 +53,6 @@ spring:
org.quartz.jobStore.isClustered: false
org.quartz.jobStore.misfireThreshold: 60000
- # Kafka Configuration
- kafka:
- bootstrap-servers: localhost:9092
- producer:
- key-serializer: org.apache.kafka.common.serialization.StringSerializer
- value-serializer: org.apache.kafka.common.serialization.StringSerializer
- acks: all
- retries: 3
- properties:
- enable.idempotence: true
- compression.type: snappy
- linger.ms: 20
- batch.size: 65536
- max.block.ms: 3000
- request.timeout.ms: 5000
- delivery.timeout.ms: 10000
# Server Configuration
server:
@@ -181,63 +165,21 @@ app:
max-retry-count: 3 # 최대 재시도 횟수
partition-count: 4 # 병렬 파티션 수
- # AIS Target Import 배치 설정 (캐시 업데이트 전용)
- ais-target:
- since-seconds: 60 # API 조회 범위 (초)
- chunk-size: 50000 # 배치 청크 크기
- schedule:
- cron: "15 * * * * ?" # 매 분 15초 실행
- kafka:
- enabled: false # true로 변경 시 Kafka 브로커 연결 필요
- topic: tp_Global_AIS_Signal
- send-chunk-size: 5000
- fail-on-send-error: false
-
- # AIS Target DB Sync 배치 설정 (캐시 → DB 저장)
- ais-target-db-sync:
- time-range-minutes: 15 # 캐시에서 조회할 시간 범위 (분)
- schedule:
- cron: "0 0/15 * * * ?" # 매 15분 정각 실행 (00, 15, 30, 45분)
-
- # AIS Target 캐시 설정
- ais-target-cache:
- ttl-minutes: 120 # 캐시 TTL (분) - 2시간
- max-size: 300000 # 최대 캐시 크기 - 30만 건
-
- # 중국 허가선박 전용 캐시 설정
- chnprmship:
- mmsi-resource-path: classpath:chnprmship-mmsi.txt
- ttl-days: 2
- max-size: 2000
- warmup-enabled: true
- warmup-days: 2
-
- # ClassType 분류 설정
- class-type:
- refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
-
- # Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
- core20:
- schema: std_snp_svc # 스키마명
- table: tb_ship_main_info # 테이블명
- imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
- mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
+ # 배치 로그 정리 설정
+ log-cleanup:
+ api-log-retention-days: 30 # batch_api_log 보존 기간
+ batch-meta-retention-days: 90 # Spring Batch 메타 테이블 보존 기간
+ failed-record-retention-days: 90 # batch_failed_record (RESOLVED) 보존 기간
+ recollection-history-retention-days: 90 # batch_recollection_history 보존 기간
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
- daily-tables:
- - schema: std_snp_data
- table-name: ais_target
- partition-column: message_timestamp
- periods-ahead: 3 # 미리 생성할 일수
+ daily-tables: []
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
- monthly-tables: [] # 현재 없음
+ monthly-tables: []
# 기본 보관기간
retention:
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
- # 개별 테이블 보관기간 설정 (옵션)
- custom:
- # - table-name: ais_target
- # retention-days: 30 # ais_target만 30일 보관
+ custom: []
diff --git a/src/main/resources/chnprmship-mmsi.txt b/src/main/resources/chnprmship-mmsi.txt
deleted file mode 100644
index 5086ddc..0000000
--- a/src/main/resources/chnprmship-mmsi.txt
+++ /dev/null
@@ -1,1402 +0,0 @@
-100895843
-100915113
-150201583
-186544332
-200005740
-200026355
-210105014
-210800202
-214100000
-261088888
-313443397
-314425141
-320709591
-332154938
-333545559
-365226688
-379824585
-400108800
-400123354
-400702597
-410210118
-411225585
-411256658
-412000996
-412001266
-412002674
-412005279
-412005557
-412005999
-412014688
-412015316
-412020019
-412026089
-412026099
-412026399
-412036999
-412053898
-412054958
-412055125
-412056987
-412085668
-412113500
-412121483
-412135789
-412167777
-412200193
-412200194
-412200217
-412200377
-412200384
-412200394
-412200404
-412200414
-412200432
-412200437
-412200527
-412200528
-412200561
-412200776
-412200805
-412200812
-412200813
-412200849
-412200853
-412200877
-412200879
-412201174
-412201239
-412202172
-412202321
-412202322
-412202326
-412202327
-412202356
-412202374
-412202375
-412202377
-412202384
-412202385
-412202388
-412202413
-412202414
-412202499
-412202736
-412202741
-412202782
-412202783
-412202796
-412202797
-412202802
-412202803
-412202888
-412202969
-412202974
-412203032
-412203062
-412203388
-412203608
-412204051
-412204069
-412204155
-412204201
-412205349
-412205351
-412205422
-412205461
-412205462
-412205602
-412205603
-412205629
-412205631
-412205632
-412205647
-412205648
-412205651
-412205697
-412205699
-412205742
-412205743
-412207019
-412207076
-412207077
-412207078
-412207079
-412207463
-412207465
-412208071
-412208072
-412208081
-412208082
-412208116
-412208162
-412208166
-412208213
-412208281
-412208282
-412209061
-412210017
-412210018
-412210019
-412210021
-412210022
-412210023
-412210024
-412210025
-412210026
-412210043
-412210044
-412210048
-412210049
-412210051
-412210054
-412210056
-412210109
-412210111
-412210112
-412210113
-412210115
-412210117
-412210118
-412210121
-412210123
-412210124
-412210126
-412210127
-412210131
-412210132
-412210134
-412210135
-412210136
-412210138
-412210139
-412210142
-412210154
-412210156
-412210158
-412210161
-412210162
-412210163
-412210165
-412210246
-412210258
-412210259
-412210261
-412210273
-412210297
-412210312
-412210313
-412210314
-412210315
-412210316
-412210329
-412210331
-412210332
-412210442
-412210463
-412210466
-412210467
-412210469
-412210471
-412210472
-412210473
-412210474
-412210475
-412210477
-412210478
-412210479
-412210484
-412210487
-412210489
-412210491
-412210517
-412210518
-412210519
-412210527
-412210822
-412210871
-412210938
-412211121
-412211161
-412212504
-412212655
-412212934
-412213298
-412213299
-412213351
-412213369
-412213373
-412213374
-412213375
-412213381
-412213382
-412213383
-412213384
-412213386
-412213401
-412213403
-412213405
-412213454
-412213455
-412213457
-412213478
-412213486
-412213487
-412213488
-412213495
-412213514
-412213520
-412213521
-412213522
-412213576
-412213624
-412213626
-412213663
-412213692
-412213702
-412213708
-412213769
-412213772
-412213773
-412213774
-412213775
-412213777
-412213778
-412213779
-412214808
-412214872
-412214873
-412215031
-412215139
-412217300
-412217304
-412217305
-412217678
-412218936
-412218937
-412219066
-412219067
-412219955
-412219956
-412219986
-412221489
-412221493
-412223022
-412223024
-412223032
-412223033
-412223050
-412225088
-412225282
-412225388
-412225502
-412225509
-412225512
-412225518
-412225525
-412225585
-412225591
-412225616
-412225734
-412225738
-412225743
-412225754
-412225766
-412225773
-412225788
-412225793
-412225795
-412225797
-412225802
-412225809
-412225814
-412225835
-412225841
-412225844
-412225854
-412225863
-412225925
-412225927
-412225936
-412225938
-412225948
-412225951
-412225952
-412225954
-412225959
-412225962
-412226004
-412226023
-412226057
-412226059
-412226087
-412226088
-412226089
-412226092
-412226094
-412226095
-412226107
-412226108
-412226109
-412226114
-412226115
-412226129
-412226151
-412226153
-412226205
-412226209
-412226318
-412226319
-412226321
-412226324
-412226388
-412229246
-412231777
-412251119
-412255855
-412256658
-412256789
-412258598
-412258777
-412265777
-412265888
-412280063
-412280237
-412280376
-412280377
-412280739
-412280741
-412280841
-412280842
-412284608
-412285646
-412286361
-412286362
-412286368
-412286369
-412286529
-412286540
-412286655
-412286661
-412286662
-412286666
-412286668
-412286669
-412286671
-412286672
-412286673
-412286674
-412286675
-412286677
-412286682
-412286684
-412286685
-412286686
-412286687
-412286688
-412286715
-412287545
-412287668
-412287669
-412287708
-412287709
-412287711
-412287712
-412287713
-412287748
-412287752
-412287753
-412287756
-412287757
-412287766
-412287767
-412287771
-412287772
-412287773
-412287774
-412287775
-412287776
-412287782
-412287783
-412287784
-412287804
-412287805
-412287812
-412287822
-412287824
-412287844
-412287861
-412287874
-412287877
-412287878
-412287918
-412289281
-412296865
-412300005
-412300006
-412300011
-412300012
-412300013
-412300026
-412300028
-412300029
-412300031
-412300032
-412300033
-412300034
-412300035
-412300036
-412300037
-412300038
-412300042
-412300043
-412300044
-412300046
-412300053
-412300054
-412300055
-412300056
-412300062
-412300064
-412300065
-412300066
-412300068
-412300069
-412300071
-412300084
-412300087
-412300146
-412300189
-412300233
-412300249
-412300292
-412300307
-412300332
-412300346
-412300504
-412300517
-412300817
-412301005
-412301006
-412301041
-412301063
-412301088
-412304086
-412304899
-412305328
-412305988
-412306396
-412306399
-412306663
-412306788
-412306887
-412308689
-412309679
-412311132
-412313345
-412314158
-412317827
-412319975
-412320009
-412320018
-412320035
-412320043
-412320045
-412320069
-412320091
-412320092
-412320093
-412320094
-412320122
-412320123
-412320151
-412320162
-412320163
-412320166
-412320167
-412320168
-412320257
-412320258
-412320274
-412320279
-412320315
-412320358
-412320393
-412320394
-412320404
-412320413
-412320414
-412320475
-412320476
-412320491
-412320492
-412320501
-412320511
-412320529
-412320599
-412320601
-412320625
-412320626
-412320646
-412320647
-412320706
-412320745
-412320746
-412320783
-412320784
-412320789
-412320805
-412320836
-412320837
-412320959
-412320961
-412320962
-412320963
-412321053
-412321054
-412321115
-412321116
-412321312
-412321339
-412321341
-412321346
-412321372
-412321373
-412321387
-412321516
-412321517
-412321624
-412321686
-412321718
-412321719
-412321797
-412321802
-412321865
-412322075
-412322114
-412322145
-412322148
-412322149
-412322174
-412322175
-412324015
-412324761
-412324808
-412325033
-412325034
-412325055
-412325056
-412325218
-412325219
-412325222
-412325223
-412325249
-412325251
-412325257
-412325279
-412325304
-412325386
-412325443
-412325533
-412325813
-412325936
-412326016
-412326017
-412326817
-412326835
-412326836
-412327066
-412327646
-412327647
-412327672
-412327673
-412327735
-412327736
-412327749
-412327751
-412327752
-412327753
-412327771
-412327772
-412327819
-412327821
-412327824
-412327825
-412327844
-412327845
-412327846
-412327847
-412327865
-412327866
-412327867
-412327868
-412327890
-412327897
-412327898
-412327908
-412327922
-412327923
-412327926
-412327927
-412327928
-412327929
-412327933
-412327934
-412327944
-412327945
-412327974
-412328111
-412328112
-412328113
-412328114
-412328115
-412328116
-412328285
-412328286
-412328287
-412328288
-412328294
-412328295
-412328301
-412328302
-412328304
-412328345
-412328346
-412328366
-412328372
-412328373
-412328384
-412328385
-412328386
-412328409
-412328411
-412328443
-412328444
-412328466
-412328467
-412328501
-412328502
-412328657
-412328658
-412328814
-412328815
-412328835
-412328836
-412328847
-412328848
-412328878
-412328894
-412328895
-412328897
-412328898
-412328905
-412328906
-412328907
-412328908
-412328923
-412328924
-412328934
-412328935
-412328936
-412328937
-412328942
-412328943
-412328944
-412328945
-412328965
-412328966
-412328989
-412328991
-412328996
-412328997
-412329001
-412329002
-412329006
-412329007
-412329078
-412329089
-412329091
-412329095
-412329096
-412329117
-412329134
-412329135
-412329148
-412329149
-412329173
-412329174
-412329176
-412329177
-412329183
-412329184
-412329211
-412329212
-412329215
-412329216
-412329245
-412329246
-412329289
-412329291
-412329316
-412329317
-412329321
-412329322
-412329323
-412329324
-412329374
-412329375
-412329396
-412329397
-412329398
-412329399
-412329489
-412329491
-412329492
-412329493
-412329551
-412329552
-412329614
-412329615
-412329759
-412329761
-412329782
-412329786
-412329788
-412329789
-412329803
-412329804
-412329808
-412329809
-412329817
-412329831
-412329832
-412329833
-412329847
-412329848
-412329892
-412329893
-412329901
-412329902
-412329916
-412329917
-412329919
-412329921
-412329924
-412329925
-412329926
-412329927
-412329934
-412329935
-412329941
-412329977
-412329982
-412329983
-412329986
-412329987
-412329988
-412329995
-412329996
-412330022
-412330023
-412330024
-412330027
-412330028
-412330476
-412330477
-412330503
-412330504
-412330505
-412330506
-412330522
-412330523
-412330524
-412330525
-412330545
-412330546
-412330554
-412330555
-412330558
-412330559
-412330569
-412330572
-412330573
-412330574
-412330575
-412330576
-412330577
-412330578
-412330579
-412330588
-412330589
-412330594
-412330595
-412330635
-412330636
-412330657
-412330862
-412330886
-412330887
-412330888
-412330889
-412330911
-412330912
-412331194
-412331195
-412331196
-412331197
-412331198
-412331199
-412331206
-412331207
-412331396
-412331397
-412331528
-412331529
-412331535
-412331847
-412332398
-412332808
-412333324
-412333325
-412333326
-412333327
-412333342
-412333343
-412333531
-412333532
-412333541
-412333550
-412333945
-412333946
-412334006
-412334007
-412334014
-412334015
-412334019
-412334027
-412334058
-412336074
-412336093
-412336094
-412336095
-412336102
-412336111
-412336116
-412336117
-412336118
-412336123
-412336129
-412336131
-412336132
-412336196
-412336606
-412336607
-412336612
-412336613
-412336623
-412336624
-412336637
-412336638
-412337325
-412337348
-412337349
-412337424
-412337644
-412337645
-412345621
-412350017
-412350047
-412350049
-412350058
-412350059
-412350112
-412350165
-412350338
-412352301
-412352381
-412352422
-412352436
-412352649
-412353058
-412353373
-412353857
-412353858
-412353886
-412355071
-412355141
-412356251
-412357799
-412358545
-412358882
-412358995
-412359066
-412359077
-412364135
-412364283
-412364303
-412364358
-412364513
-412364738
-412364783
-412364837
-412364947
-412365095
-412365194
-412365289
-412365328
-412365331
-412365335
-412365639
-412365939
-412366336
-412366358
-412366665
-412366669
-412366912
-412368875
-412368885
-412368902
-412368966
-412375283
-412386668
-412386669
-412410001
-412410009
-412410746
-412410747
-412411528
-412411605
-412411647
-412411909
-412413895
-412414342
-412414345
-412414423
-412414436
-412414538
-412414744
-412415482
-412415513
-412416104
-412416132
-412416207
-412416235
-412416249
-412416268
-412416269
-412416292
-412416296
-412416307
-412416308
-412416338
-412416367
-412416391
-412416394
-412416406
-412416448
-412416508
-412416535
-412416554
-412416557
-412416584
-412416591
-412416592
-412416595
-412416642
-412416699
-412416837
-412416842
-412416872
-412416875
-412416898
-412416927
-412416949
-412416981
-412417008
-412417106
-412417115
-412417151
-412417182
-412417188
-412417222
-412417247
-412417248
-412417287
-412417288
-412417295
-412417311
-412417334
-412417335
-412417338
-412417352
-412417365
-412417368
-412417412
-412417413
-412417483
-412417509
-412417556
-412417692
-412417712
-412417741
-412417785
-412417807
-412417825
-412417838
-412417851
-412417917
-412417954
-412417957
-412417977
-412417981
-412418011
-412418017
-412418018
-412418056
-412418082
-412418101
-412418158
-412418171
-412418185
-412418246
-412418319
-412418387
-412418401
-412418478
-412418488
-412418507
-412418511
-412418513
-412418515
-412418567
-412418568
-412418586
-412418629
-412418633
-412418679
-412418696
-412418698
-412418774
-412418785
-412418793
-412418795
-412418803
-412418814
-412418816
-412418833
-412418834
-412418872
-412418873
-412418874
-412418887
-412418918
-412418933
-412418941
-412418942
-412418952
-412418999
-412419018
-412419024
-412419064
-412419114
-412419132
-412419203
-412419233
-412419262
-412419264
-412419265
-412419266
-412419276
-412419324
-412419342
-412419345
-412419348
-412419406
-412419407
-412419455
-412419488
-412419495
-412419502
-412419506
-412419507
-412419509
-412419531
-412419536
-412419541
-412419544
-412419545
-412419549
-412419553
-412419564
-412419569
-412419585
-412419587
-412419638
-412419641
-412419642
-412419667
-412419668
-412419688
-412419689
-412419701
-412419702
-412419703
-412419704
-412419706
-412419709
-412425002
-412431008
-412431029
-412431033
-412431041
-412431058
-412431063
-412431066
-412431071
-412431084
-412431087
-412431120
-412431124
-412431129
-412431141
-412431151
-412431173
-412431222
-412431262
-412431263
-412431266
-412431291
-412431396
-412431491
-412431494
-412431743
-412431805
-412431809
-412431892
-412431911
-412431913
-412431914
-412431915
-412431963
-412431964
-412431966
-412431985
-412435125
-412435126
-412435127
-412435142
-412435309
-412435356
-412435386
-412435387
-412435595
-412435596
-412435784
-412435813
-412436079
-412436271
-412436329
-412436519
-412436521
-412436627
-412436631
-412436701
-412436710
-412436841
-412436874
-412436963
-412436969
-412436992
-412437006
-412437026
-412437037
-412437045
-412437054
-412437055
-412437071
-412437072
-412437079
-412437085
-412437095
-412437113
-412437118
-412437119
-412437166
-412437215
-412437418
-412437419
-412437626
-412437627
-412437633
-412437635
-412437659
-412437718
-412437817
-412437818
-412437821
-412437822
-412437988
-412437989
-412438043
-412438044
-412438045
-412438065
-412438066
-412438146
-412438235
-412438236
-412438646
-412438647
-412438696
-412438697
-412438868
-412438869
-412438873
-412438955
-412438996
-412438997
-412439055
-412439056
-412439111
-412439112
-412439139
-412439143
-412439145
-412439146
-412439252
-412439356
-412439357
-412443647
-412452265
-412456855
-412468166
-412471879
-412475803
-412476361
-412476457
-412479103
-412479385
-412480093
-412494141
-412494148
-412494156
-412494172
-412515088
-412526198
-412526798
-412532666
-412545687
-412556356
-412556357
-412556889
-412570001
-412577688
-412585665
-412588681
-412588778
-412653456
-412665478
-412685177
-412752470
-412798948
-412800888
-412852443
-412865966
-412879798
-412885120
-412886580
-412900240
-412952867
-412958588
-413000229
-413000769
-413004860
-413005116
-413027088
-413035319
-413089562
-413111322
-413122960
-413127608
-413155153
-413215238
-413216847
-413226089
-413245082
-413255506
-413255555
-413256667
-413296865
-413300026
-413300221
-413315088
-413320282
-413327929
-413335198
-413350165
-413357867
-413361808
-413388589
-413457866
-413464232
-413466077
-413520688
-413578254
-413593750
-413699592
-414328943
-415005666
-415051026
-415107777
-415108888
-415109607
-415140625
-415214102
-415232125
-415261386
-415506055
-415628585
-415782000
-415836666
-415901572
-415936288
-417758521
-420439112
-421233456
-422226789
-422277709
-423678955
-423789666
-425556789
-441235678
-512325936
-550026918
-558888888
-586358882
-586418965
-600950945
-688816888
-688826755
-688826968
-712210938
-712330656
-712888888
-789999999
-800004551
-800029774
-800044382
-800052359
-888888838
-888888877
-888888988
-888898888
-900000173
-900020650
-905106399
-905106699
-905108588
-926002285
-926002997
-926004879
-926009286
-926012291