[추가]
- 실시간 선박 위치 조회 API Classtype 구분 파라미터 추가 (core20 테이블 imo 유무로 ClassA, ClassB 분류) - html PUT,DELETE, PATCH 메소드 제거 및 POST 메소드 사용 변경 (보안이슈)
This commit is contained in:
부모
18ab11068a
커밋
3dde3d0167
@ -2,10 +2,12 @@ package com.snp.batch;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan
|
||||
public class SnpBatchApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@ -96,7 +96,7 @@ public class MaritimeApiWebClientConfig {
|
||||
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||
.codecs(configurer -> configurer
|
||||
.defaultCodecs()
|
||||
.maxInMemorySize(30 * 1024 * 1024)) // 30MB 버퍼
|
||||
.maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,7 +178,7 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/schedules/{jobName}")
|
||||
@PostMapping("/schedules/{jobName}/update")
|
||||
public ResponseEntity<Map<String, Object>> updateSchedule(
|
||||
@PathVariable String jobName,
|
||||
@RequestBody Map<String, String> request) {
|
||||
@ -206,7 +206,7 @@ public class BatchController {
|
||||
@ApiResponse(responseCode = "200", description = "삭제 성공"),
|
||||
@ApiResponse(responseCode = "500", description = "삭제 실패")
|
||||
})
|
||||
@DeleteMapping("/schedules/{jobName}")
|
||||
@PostMapping("/schedules/{jobName}/delete")
|
||||
public ResponseEntity<Map<String, Object>> deleteSchedule(
|
||||
@Parameter(description = "배치 작업 이름", required = true)
|
||||
@PathVariable String jobName) {
|
||||
@ -226,7 +226,7 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
@PatchMapping("/schedules/{jobName}/toggle")
|
||||
@PostMapping("/schedules/{jobName}/toggle")
|
||||
public ResponseEntity<Map<String, Object>> toggleSchedule(
|
||||
@PathVariable String jobName,
|
||||
@RequestBody Map<String, Boolean> request) {
|
||||
|
||||
@ -6,6 +6,7 @@ import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.processor.AisTargetDataProcessor;
|
||||
import com.snp.batch.jobs.aistarget.batch.reader.AisTargetDataReader;
|
||||
import com.snp.batch.jobs.aistarget.batch.writer.AisTargetDataWriter;
|
||||
import com.snp.batch.jobs.aistarget.classifier.Core20CacheManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
@ -43,6 +44,7 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
|
||||
private final AisTargetDataProcessor aisTargetDataProcessor;
|
||||
private final AisTargetDataWriter aisTargetDataWriter;
|
||||
private final WebClient maritimeAisApiWebClient;
|
||||
private final Core20CacheManager core20CacheManager;
|
||||
|
||||
@Value("${app.batch.ais-target.since-seconds:60}")
|
||||
private int sinceSeconds;
|
||||
@ -55,11 +57,13 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
|
||||
PlatformTransactionManager transactionManager,
|
||||
AisTargetDataProcessor aisTargetDataProcessor,
|
||||
AisTargetDataWriter aisTargetDataWriter,
|
||||
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient) {
|
||||
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient,
|
||||
Core20CacheManager core20CacheManager) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.aisTargetDataProcessor = aisTargetDataProcessor;
|
||||
this.aisTargetDataWriter = aisTargetDataWriter;
|
||||
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
|
||||
this.core20CacheManager = core20CacheManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -101,16 +105,29 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
|
||||
OffsetDateTime collectedAt = OffsetDateTime.now();
|
||||
aisTargetDataProcessor.setCollectedAt(collectedAt);
|
||||
log.info("[{}] Job 시작 - 수집 시점: {}", getJobName(), collectedAt);
|
||||
|
||||
// Core20 캐시 관리
|
||||
// 1. 캐시가 비어있으면 즉시 로딩 (첫 실행 또는 재시작 시)
|
||||
// 2. 지정된 시간대(기본 04:00)이면 일일 갱신 수행
|
||||
if (!core20CacheManager.isLoaded()) {
|
||||
log.info("[{}] Core20 캐시 초기 로딩 시작", getJobName());
|
||||
core20CacheManager.refresh();
|
||||
} else if (core20CacheManager.shouldRefresh()) {
|
||||
log.info("[{}] Core20 캐시 일일 갱신 시작 (스케줄: {}시)",
|
||||
getJobName(), core20CacheManager.getLastRefreshTime());
|
||||
core20CacheManager.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterJob(JobExecution jobExecution) {
|
||||
log.info("[{}] Job 완료 - 상태: {}, 처리 건수: {}",
|
||||
log.info("[{}] Job 완료 - 상태: {}, 처리 건수: {}, Core20 캐시 크기: {}",
|
||||
getJobName(),
|
||||
jobExecution.getStatus(),
|
||||
jobExecution.getStepExecutions().stream()
|
||||
.mapToLong(se -> se.getWriteCount())
|
||||
.sum());
|
||||
.sum(),
|
||||
core20CacheManager.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -82,4 +82,21 @@ public class AisTargetEntity extends BaseEntity {
|
||||
// ========== 타임스탬프 ==========
|
||||
private OffsetDateTime receivedDate;
|
||||
private OffsetDateTime collectedAt; // 배치 수집 시점
|
||||
|
||||
// ========== ClassType 분류 정보 ==========
|
||||
/**
|
||||
* 선박 클래스 타입
|
||||
* - "A": Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
* - "B": Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
* - null: 미분류 (캐시 저장 전)
|
||||
*/
|
||||
private String classType;
|
||||
|
||||
/**
|
||||
* Core20 테이블의 MMSI 값
|
||||
* - Class A인 경우에만 값이 있을 수 있음
|
||||
* - Class A이지만 Core20에 MMSI가 없는 경우 null
|
||||
* - Class B인 경우 항상 null
|
||||
*/
|
||||
private String core20Mmsi;
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
received_date, collected_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326),
|
||||
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
@ -203,9 +203,9 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM %s
|
||||
WHERE message_timestamp BETWEEN ? AND ?
|
||||
AND public.ST_DWithin(
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
|
||||
?
|
||||
)
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
|
||||
@ -4,6 +4,7 @@ import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@ -13,8 +14,9 @@ import java.util.List;
|
||||
* AIS Target 데이터 Writer
|
||||
*
|
||||
* 동작:
|
||||
* - UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp)
|
||||
* - 동시에 캐시에도 최신 위치 정보 업데이트
|
||||
* 1. UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp)
|
||||
* 2. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
||||
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -22,23 +24,30 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
||||
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final AisClassTypeClassifier classTypeClassifier;
|
||||
|
||||
public AisTargetDataWriter(
|
||||
AisTargetRepository aisTargetRepository,
|
||||
AisTargetCacheManager cacheManager) {
|
||||
AisTargetCacheManager cacheManager,
|
||||
AisClassTypeClassifier classTypeClassifier) {
|
||||
super("AisTarget");
|
||||
this.aisTargetRepository = aisTargetRepository;
|
||||
this.cacheManager = cacheManager;
|
||||
this.classTypeClassifier = classTypeClassifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<AisTargetEntity> items) throws Exception {
|
||||
log.debug("AIS Target 데이터 저장 시작: {} 건", items.size());
|
||||
|
||||
// 1. DB 저장
|
||||
// 1. DB 저장 (classType 없이 원본 데이터만 저장)
|
||||
aisTargetRepository.batchUpsert(items);
|
||||
|
||||
// 2. 캐시 업데이트 (최신 위치 정보)
|
||||
// 2. ClassType 분류 (캐시 저장 전에 분류)
|
||||
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
||||
classTypeClassifier.classifyAll(items);
|
||||
|
||||
// 3. 캐시 업데이트 (classType, core20Mmsi 포함)
|
||||
cacheManager.putAll(items);
|
||||
|
||||
log.debug("AIS Target 데이터 저장 완료: {} 건 (캐시 크기: {})",
|
||||
|
||||
@ -2,10 +2,12 @@ package com.snp.batch.jobs.aistarget.cache;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.NumericCondition;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ -16,6 +18,7 @@ import java.util.stream.Collectors;
|
||||
* - SOG, COG, Heading: 숫자 범위 조건
|
||||
* - Destination: 문자열 부분 일치
|
||||
* - Status: 다중 선택 일치
|
||||
* - ClassType: 선박 클래스 타입 (A/B)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -45,6 +48,7 @@ public class AisTargetFilterUtil {
|
||||
.filter(entity -> matchesHeading(entity, request))
|
||||
.filter(entity -> matchesDestination(entity, request))
|
||||
.filter(entity -> matchesStatus(entity, request))
|
||||
.filter(entity -> matchesClassType(entity, request))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
@ -54,6 +58,54 @@ public class AisTargetFilterUtil {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* AisTargetSearchRequest 기반 ClassType 필터링
|
||||
*
|
||||
* @param entities 원본 엔티티 목록
|
||||
* @param request 검색 조건
|
||||
* @return 필터링된 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByClassType(List<AisTargetEntity> entities, AisTargetSearchRequest request) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!request.hasClassTypeFilter()) {
|
||||
return entities;
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> matchesClassType(entity, request.getClassType()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("ClassType 필터링 완료 - 입력: {}, 결과: {}, 필터: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), request.getClassType(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 classType으로 직접 필터링
|
||||
*/
|
||||
private boolean matchesClassType(AisTargetEntity entity, String classTypeFilter) {
|
||||
if (classTypeFilter == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityClassType = entity.getClassType();
|
||||
|
||||
// classType이 미분류(null)인 데이터 처리
|
||||
if (entityClassType == null) {
|
||||
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
|
||||
return "B".equalsIgnoreCase(classTypeFilter);
|
||||
}
|
||||
|
||||
return classTypeFilter.equalsIgnoreCase(entityClassType);
|
||||
}
|
||||
|
||||
/**
|
||||
* SOG (속도) 조건 매칭
|
||||
*/
|
||||
@ -150,4 +202,28 @@ public class AisTargetFilterUtil {
|
||||
return request.getStatusList().stream()
|
||||
.anyMatch(status -> entityStatus.equalsIgnoreCase(status.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassType (선박 클래스 타입) 조건 매칭
|
||||
*
|
||||
* - A: Core20에 등록된 선박
|
||||
* - B: Core20 미등록 선박
|
||||
* - 필터 미지정: 전체 통과
|
||||
* - classType이 null인 데이터: B 필터에만 포함 (보수적 접근)
|
||||
*/
|
||||
private boolean matchesClassType(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasClassTypeFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityClassType = entity.getClassType();
|
||||
|
||||
// classType이 미분류(null)인 데이터 처리
|
||||
if (entityClassType == null) {
|
||||
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
|
||||
return "B".equalsIgnoreCase(request.getClassType());
|
||||
}
|
||||
|
||||
return request.getClassType().equalsIgnoreCase(entityClassType);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* AIS Target ClassType 분류기
|
||||
*
|
||||
* 분류 기준:
|
||||
* - Core20 테이블에 IMO가 등록되어 있으면 Class A
|
||||
* - 등록되어 있지 않으면 Class B (기본값)
|
||||
*
|
||||
* 분류 결과:
|
||||
* - classType: "A" 또는 "B"
|
||||
* - core20Mmsi: Core20에 등록된 MMSI (Class A일 때만, nullable)
|
||||
*
|
||||
* 특이 케이스:
|
||||
* 1. IMO가 0이거나 null → Class B
|
||||
* 2. IMO가 7자리가 아닌 의미없는 숫자 → Class B
|
||||
* 3. IMO가 7자리이지만 Core20에 미등록 → Class B
|
||||
* 4. IMO가 Core20에 있지만 MMSI가 null → Class A, core20Mmsi = null
|
||||
*
|
||||
* 향후 제거 가능하도록 독립적인 모듈로 구현
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisClassTypeClassifier {
|
||||
|
||||
/**
|
||||
* 유효한 IMO 패턴 (7자리 숫자)
|
||||
*/
|
||||
private static final Pattern IMO_PATTERN = Pattern.compile("^\\d{7}$");
|
||||
|
||||
private final Core20CacheManager core20CacheManager;
|
||||
|
||||
/**
|
||||
* ClassType 분류 기능 활성화 여부
|
||||
*/
|
||||
@Value("${app.batch.class-type.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
public AisClassTypeClassifier(Core20CacheManager core20CacheManager) {
|
||||
this.core20CacheManager = core20CacheManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 Entity의 ClassType 분류
|
||||
*
|
||||
* @param entity AIS Target Entity
|
||||
*/
|
||||
public void classify(AisTargetEntity entity) {
|
||||
if (!enabled || entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long imo = entity.getImo();
|
||||
|
||||
// 1. IMO가 null이거나 0이면 Class B
|
||||
if (imo == null || imo == 0) {
|
||||
setClassB(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. IMO가 7자리 숫자인지 확인
|
||||
String imoStr = String.valueOf(imo);
|
||||
if (!isValidImo(imoStr)) {
|
||||
setClassB(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Core20 캐시에서 IMO 존재 여부 확인
|
||||
if (core20CacheManager.containsImo(imoStr)) {
|
||||
// Class A - Core20에 등록된 선박
|
||||
entity.setClassType("A");
|
||||
|
||||
// Core20의 MMSI 조회 (nullable - Core20에 MMSI가 없을 수도 있음)
|
||||
Optional<String> core20Mmsi = core20CacheManager.getMmsiByImo(imoStr);
|
||||
entity.setCore20Mmsi(core20Mmsi.orElse(null));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Core20에 없음 - Class B
|
||||
setClassB(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 Entity 일괄 분류
|
||||
*
|
||||
* @param entities AIS Target Entity 목록
|
||||
*/
|
||||
public void classifyAll(List<AisTargetEntity> entities) {
|
||||
if (!enabled || entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int classACount = 0;
|
||||
int classBCount = 0;
|
||||
int classAWithMmsi = 0;
|
||||
int classAWithoutMmsi = 0;
|
||||
|
||||
for (AisTargetEntity entity : entities) {
|
||||
classify(entity);
|
||||
|
||||
if ("A".equals(entity.getClassType())) {
|
||||
classACount++;
|
||||
if (entity.getCore20Mmsi() != null) {
|
||||
classAWithMmsi++;
|
||||
} else {
|
||||
classAWithoutMmsi++;
|
||||
}
|
||||
} else {
|
||||
classBCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("ClassType 분류 완료 - 총: {}, Class A: {} (MMSI있음: {}, MMSI없음: {}), Class B: {}",
|
||||
entities.size(), classACount, classAWithMmsi, classAWithoutMmsi, classBCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class B로 설정 (기본값)
|
||||
*/
|
||||
private void setClassB(AisTargetEntity entity) {
|
||||
entity.setClassType("B");
|
||||
entity.setCore20Mmsi(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 IMO 번호인지 확인 (7자리 숫자)
|
||||
*
|
||||
* @param imo IMO 문자열
|
||||
* @return 유효 여부
|
||||
*/
|
||||
private boolean isValidImo(String imo) {
|
||||
return imo != null && IMO_PATTERN.matcher(imo).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 활성화 여부
|
||||
*/
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core20 캐시 상태 확인
|
||||
*/
|
||||
public boolean isCacheReady() {
|
||||
return core20CacheManager.isLoaded();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Core20 테이블의 IMO → MMSI 매핑 캐시 매니저
|
||||
*
|
||||
* 동작:
|
||||
* - 애플리케이션 시작 시 또는 첫 조회 시 자동 로딩
|
||||
* - 매일 지정된 시간(기본 04:00)에 전체 갱신
|
||||
* - TTL 없음 (명시적 갱신만)
|
||||
*
|
||||
* 데이터 구조:
|
||||
* - Key: IMO/LRNO (7자리 문자열, NOT NULL)
|
||||
* - Value: MMSI (문자열, NULLABLE - 빈 문자열로 저장)
|
||||
*
|
||||
* 특이사항:
|
||||
* - Core20에 IMO는 있지만 MMSI가 null인 경우도 존재
|
||||
* - 이 경우 containsImo()는 true, getMmsiByImo()는 Optional.empty()
|
||||
* - ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열("")을 sentinel 값으로 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class Core20CacheManager {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Core20Properties properties;
|
||||
|
||||
/**
|
||||
* MMSI가 없는 경우를 나타내는 sentinel 값
|
||||
* ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열 사용
|
||||
*/
|
||||
private static final String NO_MMSI = "";
|
||||
|
||||
/**
|
||||
* IMO → MMSI 매핑 캐시
|
||||
* - Key: IMO (NOT NULL)
|
||||
* - Value: MMSI (빈 문자열이면 MMSI 없음)
|
||||
*/
|
||||
private volatile Map<String, String> imoToMmsiMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시간
|
||||
*/
|
||||
private volatile LocalDateTime lastRefreshTime;
|
||||
|
||||
/**
|
||||
* Core20 캐시 갱신 시간 (기본: 04시)
|
||||
*/
|
||||
@Value("${app.batch.class-type.refresh-hour:4}")
|
||||
private int refreshHour;
|
||||
|
||||
public Core20CacheManager(JdbcTemplate jdbcTemplate, Core20Properties properties) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO로 MMSI 조회
|
||||
*
|
||||
* @param imo IMO 번호 (문자열)
|
||||
* @return MMSI 값 (없거나 null/빈 문자열이면 Optional.empty)
|
||||
*/
|
||||
public Optional<String> getMmsiByImo(String imo) {
|
||||
ensureCacheLoaded();
|
||||
|
||||
if (imo == null || !imoToMmsiMap.containsKey(imo)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String mmsi = imoToMmsiMap.get(imo);
|
||||
|
||||
// MMSI가 빈 문자열(NO_MMSI)인 경우
|
||||
if (mmsi == null || mmsi.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(mmsi);
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO 존재 여부만 확인 (MMSI 유무와 무관)
|
||||
* - Core20에 등록된 선박인지 판단하는 용도
|
||||
* - MMSI가 null이어도 IMO가 있으면 true
|
||||
*
|
||||
* @param imo IMO 번호
|
||||
* @return Core20에 등록 여부
|
||||
*/
|
||||
public boolean containsImo(String imo) {
|
||||
ensureCacheLoaded();
|
||||
return imo != null && imoToMmsiMap.containsKey(imo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 전체 갱신 (DB에서 다시 로딩)
|
||||
*/
|
||||
public synchronized void refresh() {
|
||||
log.info("Core20 캐시 갱신 시작 - 테이블: {}", properties.getFullTableName());
|
||||
|
||||
try {
|
||||
String sql = properties.buildSelectSql();
|
||||
log.debug("Core20 조회 SQL: {}", sql);
|
||||
|
||||
Map<String, String> newMap = new ConcurrentHashMap<>();
|
||||
|
||||
jdbcTemplate.query(sql, rs -> {
|
||||
String imo = rs.getString(1);
|
||||
String mmsi = rs.getString(2); // nullable
|
||||
|
||||
if (imo != null && !imo.isBlank()) {
|
||||
// IMO는 trim하여 저장, MMSI는 빈 문자열로 대체 (ConcurrentHashMap은 null 불가)
|
||||
String trimmedImo = imo.trim();
|
||||
String trimmedMmsi = (mmsi != null && !mmsi.isBlank()) ? mmsi.trim() : NO_MMSI;
|
||||
newMap.put(trimmedImo, trimmedMmsi);
|
||||
}
|
||||
});
|
||||
|
||||
this.imoToMmsiMap = newMap;
|
||||
this.lastRefreshTime = LocalDateTime.now();
|
||||
|
||||
// 통계 로깅
|
||||
long withMmsi = newMap.values().stream()
|
||||
.filter(v -> !v.isEmpty())
|
||||
.count();
|
||||
|
||||
log.info("Core20 캐시 갱신 완료 - 총 {} 건 (MMSI 있음: {} 건, MMSI 없음: {} 건)",
|
||||
newMap.size(), withMmsi, newMap.size() - withMmsi);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Core20 캐시 갱신 실패: {}", e.getMessage(), e);
|
||||
// 기존 캐시 유지 (실패해도 서비스 중단 방지)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시가 비어있으면 자동 로딩
|
||||
*/
|
||||
private void ensureCacheLoaded() {
|
||||
if (imoToMmsiMap.isEmpty() && lastRefreshTime == null) {
|
||||
log.warn("Core20 캐시 비어있음 - 자동 로딩 실행");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 시간대에 갱신이 필요한지 확인
|
||||
* - 기본: 04:00 ~ 04:01 사이
|
||||
* - 같은 날 이미 갱신했으면 스킵
|
||||
*
|
||||
* @return 갱신 필요 여부
|
||||
*/
|
||||
public boolean shouldRefresh() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int currentHour = now.getHour();
|
||||
int currentMinute = now.getMinute();
|
||||
|
||||
// 지정된 시간(예: 04:00~04:01) 체크
|
||||
if (currentHour != refreshHour || currentMinute > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오늘 해당 시간에 이미 갱신했으면 스킵
|
||||
if (lastRefreshTime != null &&
|
||||
lastRefreshTime.toLocalDate().equals(now.toLocalDate()) &&
|
||||
lastRefreshTime.getHour() == refreshHour) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐시 크기
|
||||
*/
|
||||
public int size() {
|
||||
return imoToMmsiMap.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시간
|
||||
*/
|
||||
public LocalDateTime getLastRefreshTime() {
|
||||
return lastRefreshTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시가 로드되었는지 확인
|
||||
*/
|
||||
public boolean isLoaded() {
|
||||
return lastRefreshTime != null && !imoToMmsiMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회 (모니터링/디버깅용)
|
||||
*/
|
||||
public Map<String, Object> getStats() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalCount", imoToMmsiMap.size());
|
||||
stats.put("withMmsiCount", imoToMmsiMap.values().stream()
|
||||
.filter(v -> !v.isEmpty()).count());
|
||||
stats.put("withoutMmsiCount", imoToMmsiMap.values().stream()
|
||||
.filter(String::isEmpty).count());
|
||||
stats.put("lastRefreshTime", lastRefreshTime);
|
||||
stats.put("refreshHour", refreshHour);
|
||||
stats.put("tableName", properties.getFullTableName());
|
||||
stats.put("imoColumn", properties.getImoColumn());
|
||||
stats.put("mmsiColumn", properties.getMmsiColumn());
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Core20 테이블 설정 프로퍼티
|
||||
*
|
||||
* 환경별(dev/qa/prod)로 테이블명, 컬럼명이 다를 수 있으므로
|
||||
* 프로파일별 설정 파일에서 지정할 수 있도록 구성
|
||||
*
|
||||
* 사용 예:
|
||||
* - dev: snp_data.core20 (ihslrorimoshipno, maritimemobileserviceidentitymmsinumber)
|
||||
* - prod: new_snp.core20 (lrno, mmsi)
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "app.batch.core20")
|
||||
public class Core20Properties {
|
||||
|
||||
/**
|
||||
* 스키마명 (예: snp_data, new_snp)
|
||||
*/
|
||||
private String schema = "snp_data";
|
||||
|
||||
/**
|
||||
* 테이블명 (예: core20)
|
||||
*/
|
||||
private String table = "core20";
|
||||
|
||||
/**
|
||||
* IMO/LRNO 컬럼명 (PK, NOT NULL)
|
||||
*/
|
||||
private String imoColumn = "ihslrorimoshipno";
|
||||
|
||||
/**
|
||||
* MMSI 컬럼명 (NULLABLE)
|
||||
*/
|
||||
private String mmsiColumn = "maritimemobileserviceidentitymmsinumber";
|
||||
|
||||
/**
|
||||
* 전체 테이블명 반환 (schema.table)
|
||||
*/
|
||||
public String getFullTableName() {
|
||||
if (schema != null && !schema.isBlank()) {
|
||||
return schema + "." + table;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT 쿼리 생성
|
||||
* IMO가 NOT NULL인 레코드만 조회
|
||||
*/
|
||||
public String buildSelectSql() {
|
||||
return String.format(
|
||||
"SELECT %s, %s FROM %s WHERE %s IS NOT NULL",
|
||||
imoColumn, mmsiColumn, getFullTableName(), imoColumn
|
||||
);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void logConfig() {
|
||||
log.info("Core20 설정 로드 - 테이블: {}, IMO컬럼: {}, MMSI컬럼: {}",
|
||||
getFullTableName(), imoColumn, mmsiColumn);
|
||||
}
|
||||
}
|
||||
@ -74,11 +74,23 @@ public class AisTargetController {
|
||||
@Operation(
|
||||
summary = "시간/공간 범위로 선박 검색",
|
||||
description = """
|
||||
시간 범위 (필수) + 공간 범위 (옵션)로 선박을 검색합니다.
|
||||
시간 범위 (필수) + 공간 범위 (옵션) + 선박 클래스 타입 (옵션)으로 선박을 검색합니다.
|
||||
|
||||
- minutes: 조회 범위 (분, 필수)
|
||||
- centerLon, centerLat: 중심 좌표 (옵션)
|
||||
- radiusMeters: 반경 (미터, 옵션)
|
||||
- classType: 선박 클래스 타입 필터 (A/B, 옵션)
|
||||
|
||||
---
|
||||
## ClassType 설명
|
||||
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
|
||||
---
|
||||
## 응답 필드 설명
|
||||
- **classType**: 선박 클래스 타입 (A/B)
|
||||
- **core20Mmsi**: Core20 테이블의 MMSI 값 (Class A인 경우에만 존재할 수 있음)
|
||||
|
||||
공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다.
|
||||
"""
|
||||
@ -92,16 +104,19 @@ public class AisTargetController {
|
||||
@Parameter(description = "중심 위도", example = "35.0")
|
||||
@RequestParam(required = false) Double centerLat,
|
||||
@Parameter(description = "반경 (미터)", example = "50000")
|
||||
@RequestParam(required = false) Double radiusMeters) {
|
||||
@RequestParam(required = false) Double radiusMeters,
|
||||
@Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A")
|
||||
@RequestParam(required = false) String classType) {
|
||||
|
||||
log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters);
|
||||
log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}, classType: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters, classType);
|
||||
|
||||
AisTargetSearchRequest request = AisTargetSearchRequest.builder()
|
||||
.minutes(minutes)
|
||||
.centerLon(centerLon)
|
||||
.centerLat(centerLat)
|
||||
.radiusMeters(radiusMeters)
|
||||
.classType(classType)
|
||||
.build();
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.search(request);
|
||||
@ -113,13 +128,33 @@ public class AisTargetController {
|
||||
|
||||
@Operation(
|
||||
summary = "시간/공간 범위로 선박 검색 (POST)",
|
||||
description = "POST 방식으로 검색 조건을 전달합니다"
|
||||
description = """
|
||||
POST 방식으로 검색 조건을 전달합니다.
|
||||
|
||||
---
|
||||
## 요청 예시
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"centerLon": 129.0,
|
||||
"centerLat": 35.0,
|
||||
"radiusMeters": 50000,
|
||||
"classType": "A"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## ClassType 설명
|
||||
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchPost(
|
||||
@Valid @RequestBody AisTargetSearchRequest request) {
|
||||
log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}",
|
||||
request.getMinutes(), request.hasAreaFilter());
|
||||
log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}",
|
||||
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.search(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
|
||||
@ -121,6 +121,16 @@ public class AisTargetFilterRequest {
|
||||
example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]")
|
||||
private List<String> statusList;
|
||||
|
||||
// ==================== 선박 클래스 타입 (ClassType) 필터 ====================
|
||||
@Schema(description = """
|
||||
선박 클래스 타입 필터
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
// ==================== 필터 존재 여부 확인 ====================
|
||||
|
||||
public boolean hasSogFilter() {
|
||||
@ -143,8 +153,13 @@ public class AisTargetFilterRequest {
|
||||
return statusList != null && !statusList.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasClassTypeFilter() {
|
||||
return classType != null &&
|
||||
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
|
||||
}
|
||||
|
||||
public boolean hasAnyFilter() {
|
||||
return hasSogFilter() || hasCogFilter() || hasHeadingFilter()
|
||||
|| hasDestinationFilter() || hasStatusFilter();
|
||||
|| hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@ -17,6 +18,7 @@ import java.time.OffsetDateTime;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "AIS Target 응답")
|
||||
public class AisTargetResponseDto {
|
||||
|
||||
// 선박 식별 정보
|
||||
@ -51,8 +53,26 @@ public class AisTargetResponseDto {
|
||||
private OffsetDateTime receivedDate;
|
||||
|
||||
// 데이터 소스 (캐시/DB)
|
||||
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
|
||||
private String source;
|
||||
|
||||
// ClassType 분류 정보
|
||||
@Schema(description = """
|
||||
선박 클래스 타입
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
@Schema(description = """
|
||||
Core20 테이블의 MMSI 값
|
||||
- Class A인 경우에만 값이 있을 수 있음
|
||||
- null: Class B 또는 Core20에 MMSI가 미등록된 경우
|
||||
""",
|
||||
example = "440123456", nullable = true)
|
||||
private String core20Mmsi;
|
||||
|
||||
/**
|
||||
* Entity -> DTO 변환
|
||||
*/
|
||||
@ -82,6 +102,8 @@ public class AisTargetResponseDto {
|
||||
.messageTimestamp(entity.getMessageTimestamp())
|
||||
.receivedDate(entity.getReceivedDate())
|
||||
.source(source)
|
||||
.classType(entity.getClassType())
|
||||
.core20Mmsi(entity.getCore20Mmsi())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,10 +39,28 @@ public class AisTargetSearchRequest {
|
||||
@Schema(description = "반경 (미터, 옵션)", example = "50000")
|
||||
private Double radiusMeters;
|
||||
|
||||
@Schema(description = """
|
||||
선박 클래스 타입 필터
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
/**
|
||||
* 공간 필터 사용 여부
|
||||
*/
|
||||
public boolean hasAreaFilter() {
|
||||
return centerLon != null && centerLat != null && radiusMeters != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassType 필터 사용 여부
|
||||
* - "A" 또는 "B"인 경우에만 true
|
||||
*/
|
||||
public boolean hasClassTypeFilter() {
|
||||
return classType != null &&
|
||||
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,11 +122,12 @@ public class AisTargetService {
|
||||
* 전략:
|
||||
* 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
* 2. 공간 필터 있으면 JTS로 필터링
|
||||
* 3. 캐시 데이터가 없으면 DB Fallback
|
||||
* 3. ClassType 필터 있으면 적용
|
||||
* 4. 캐시 데이터가 없으면 DB Fallback
|
||||
*/
|
||||
public List<AisTargetResponseDto> search(AisTargetSearchRequest request) {
|
||||
log.debug("선박 검색 - minutes: {}, hasArea: {}",
|
||||
request.getMinutes(), request.hasAreaFilter());
|
||||
log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}",
|
||||
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
@ -154,6 +155,11 @@ public class AisTargetService {
|
||||
);
|
||||
}
|
||||
|
||||
// 3. ClassType 필터 적용
|
||||
if (request.hasClassTypeFilter()) {
|
||||
entities = filterUtil.filterByClassType(entities, request);
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
|
||||
source, entities.size(), elapsed);
|
||||
|
||||
@ -103,6 +103,17 @@ app:
|
||||
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
|
||||
max-size: 300000 # 최대 캐시 크기 - 30만 건
|
||||
|
||||
# ClassType 분류 설정
|
||||
class-type:
|
||||
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
|
||||
|
||||
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
|
||||
core20:
|
||||
schema: snp_data # 스키마명
|
||||
table: core20 # 테이블명
|
||||
imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL)
|
||||
mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE)
|
||||
|
||||
# 파티션 관리 설정
|
||||
partition:
|
||||
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
|
||||
|
||||
@ -410,8 +410,8 @@
|
||||
if (!confirmUpdate) {
|
||||
return;
|
||||
}
|
||||
method = 'PUT';
|
||||
url = contextPath + `api/batch/schedules/${jobName}`;
|
||||
method = 'POST';
|
||||
url = contextPath + `api/batch/schedules/${jobName}/update`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
@ -455,7 +455,7 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}/toggle`, {
|
||||
method: 'PATCH',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@ -482,8 +482,8 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}`, {
|
||||
method: 'DELETE'
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}/delete`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user