feat: recent-positions 선박사진 enrichment #56
@ -53,6 +53,20 @@ public class ShipImageRepository {
|
|||||||
ORDER BY lrno, dateofphoto DESC NULLS LAST, pic_id DESC
|
ORDER BY lrno, dateofphoto DESC NULLS LAST, pic_id DESC
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
private static final String FIND_ALL_WITH_COUNT_SQL = """
|
||||||
|
SELECT s.lrno, s.pic_id, c.img_count
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (lrno) lrno, pic_id
|
||||||
|
FROM signal.t_snp_ship_img
|
||||||
|
ORDER BY lrno, dateofphoto DESC NULLS LAST, pic_id DESC
|
||||||
|
) s
|
||||||
|
JOIN (
|
||||||
|
SELECT lrno, count(*) AS img_count
|
||||||
|
FROM signal.t_snp_ship_img
|
||||||
|
GROUP BY lrno
|
||||||
|
) c ON s.lrno = c.lrno
|
||||||
|
""";
|
||||||
|
|
||||||
private static final RowMapper<ValidShipImageData> VALID_ROW_MAPPER = (rs, rowNum) -> {
|
private static final RowMapper<ValidShipImageData> VALID_ROW_MAPPER = (rs, rowNum) -> {
|
||||||
ValidShipImageData data = new ValidShipImageData();
|
ValidShipImageData data = new ValidShipImageData();
|
||||||
data.imo = rs.getInt("lrno");
|
data.imo = rs.getInt("lrno");
|
||||||
@ -60,6 +74,14 @@ public class ShipImageRepository {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static final RowMapper<ValidShipImageData> VALID_WITH_COUNT_ROW_MAPPER = (rs, rowNum) -> {
|
||||||
|
ValidShipImageData data = new ValidShipImageData();
|
||||||
|
data.imo = rs.getInt("lrno");
|
||||||
|
data.picId = rs.getInt("pic_id");
|
||||||
|
data.imageCount = rs.getInt("img_count");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선박 사진이 있는 모든 IMO의 대표 이미지 조회 (IMO당 1건, 최신 사진 우선)
|
* 선박 사진이 있는 모든 IMO의 대표 이미지 조회 (IMO당 1건, 최신 사진 우선)
|
||||||
*/
|
*/
|
||||||
@ -67,6 +89,13 @@ public class ShipImageRepository {
|
|||||||
return queryJdbcTemplate.query(FIND_ALL_VALID_SQL, VALID_ROW_MAPPER);
|
return queryJdbcTemplate.query(FIND_ALL_VALID_SQL, VALID_ROW_MAPPER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 사진이 있는 모든 IMO의 대표 이미지 + 이미지 수 조회
|
||||||
|
*/
|
||||||
|
public List<ValidShipImageData> findAllValidShipImagesWithCount() {
|
||||||
|
return queryJdbcTemplate.query(FIND_ALL_WITH_COUNT_SQL, VALID_WITH_COUNT_ROW_MAPPER);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository 내부에서 사용하는 Raw 데이터 클래스
|
* Repository 내부에서 사용하는 Raw 데이터 클래스
|
||||||
*/
|
*/
|
||||||
@ -79,5 +108,6 @@ public class ShipImageRepository {
|
|||||||
public static class ValidShipImageData {
|
public static class ValidShipImageData {
|
||||||
public Integer imo;
|
public Integer imo;
|
||||||
public Integer picId;
|
public Integer picId;
|
||||||
|
public Integer imageCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,11 +5,15 @@ import gc.mda.signal_batch.domain.ship.dto.ValidShipImageDto;
|
|||||||
import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository;
|
import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository;
|
||||||
import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ShipImageRawData;
|
import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ShipImageRawData;
|
||||||
import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ValidShipImageData;
|
import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ValidShipImageData;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -19,11 +23,42 @@ public class ShipImageService {
|
|||||||
|
|
||||||
private final ShipImageRepository shipImageRepository;
|
private final ShipImageRepository shipImageRepository;
|
||||||
|
|
||||||
|
/** IMO → {thumbnailPath, imageCount} 인메모리 캐시 (기동 시 1회 로드) */
|
||||||
|
private volatile Map<Long, ShipImageSummary> imageCache = Collections.emptyMap();
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void initImageCache() {
|
||||||
|
try {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
List<ValidShipImageData> rawList = shipImageRepository.findAllValidShipImagesWithCount();
|
||||||
|
|
||||||
|
Map<Long, ShipImageSummary> map = new ConcurrentHashMap<>(rawList.size());
|
||||||
|
for (ValidShipImageData raw : rawList) {
|
||||||
|
map.put(raw.imo.longValue(), new ShipImageSummary(
|
||||||
|
buildImagePath(raw.picId) + "_1.jpg",
|
||||||
|
raw.imageCount
|
||||||
|
));
|
||||||
|
}
|
||||||
|
imageCache = map;
|
||||||
|
|
||||||
|
log.info("ShipImage 캐시 로드 완료: {} IMO ({}ms)", map.size(), System.currentTimeMillis() - start);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("ShipImage 캐시 로드 실패 (API 조회로 대체 가능): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IMO로 캐시된 사진 요약 조회 (O(1) lookup)
|
||||||
|
*
|
||||||
|
* @return ShipImageSummary or null
|
||||||
|
*/
|
||||||
|
public ShipImageSummary getImageSummary(Long imo) {
|
||||||
|
if (imo == null || imo <= 0) return null;
|
||||||
|
return imageCache.get(imo);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMO로 이미지 정보 목록 조회
|
* IMO로 이미지 정보 목록 조회
|
||||||
*
|
|
||||||
* @param imo IMO 번호
|
|
||||||
* @return 이미지 정보 목록 (없으면 빈 배열)
|
|
||||||
*/
|
*/
|
||||||
public List<ShipImageDto> getImagesByImo(Integer imo) {
|
public List<ShipImageDto> getImagesByImo(Integer imo) {
|
||||||
List<ShipImageRawData> rawDataList = shipImageRepository.findByImo(imo);
|
List<ShipImageRawData> rawDataList = shipImageRepository.findByImo(imo);
|
||||||
@ -47,9 +82,6 @@ public class ShipImageService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw 데이터를 DTO로 변환
|
|
||||||
*/
|
|
||||||
private ShipImageDto toDto(ShipImageRawData rawData) {
|
private ShipImageDto toDto(ShipImageRawData rawData) {
|
||||||
return ShipImageDto.builder()
|
return ShipImageDto.builder()
|
||||||
.picId(rawData.picId)
|
.picId(rawData.picId)
|
||||||
@ -59,16 +91,11 @@ public class ShipImageService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* pic_id로 이미지 경로 생성
|
|
||||||
* 규칙: 폴더명 = pic_id / 100
|
|
||||||
* 예: pic_id=816100 → /shipimg/8161/816100
|
|
||||||
*
|
|
||||||
* @param picId pic_id
|
|
||||||
* @return 이미지 경로
|
|
||||||
*/
|
|
||||||
private String buildImagePath(Integer picId) {
|
private String buildImagePath(Integer picId) {
|
||||||
int folderName = picId / 100;
|
int folderName = picId / 100;
|
||||||
return String.format("/shipimg/%d/%d", folderName, picId);
|
return String.format("/shipimg/%d/%d", folderName, picId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 선박 사진 요약 정보 (인메모리 캐시용) */
|
||||||
|
public record ShipImageSummary(String thumbnailPath, int imageCount) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,4 +53,10 @@ public class RecentVesselPositionDto {
|
|||||||
@Schema(description = "최종 업데이트 시간", example = "2026-01-20 12:05:00")
|
@Schema(description = "최종 업데이트 시간", example = "2026-01-20 12:05:00")
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime lastUpdate;
|
private LocalDateTime lastUpdate;
|
||||||
|
|
||||||
|
@Schema(description = "선박 사진 썸네일 경로 (없으면 null)", example = "/shipimg/8161/816100_1.jpg")
|
||||||
|
private String shipImagePath;
|
||||||
|
|
||||||
|
@Schema(description = "선박 사진 수 (없으면 null)", example = "3")
|
||||||
|
private Integer shipImageCount;
|
||||||
}
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
package gc.mda.signal_batch.domain.vessel.service;
|
package gc.mda.signal_batch.domain.vessel.service;
|
||||||
|
|
||||||
|
import gc.mda.signal_batch.domain.ship.service.ShipImageService;
|
||||||
|
import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
|
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
|
||||||
import gc.mda.signal_batch.global.util.SignalKindCode;
|
import gc.mda.signal_batch.global.util.SignalKindCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -22,6 +24,8 @@ public class VesselPositionService {
|
|||||||
@Qualifier("queryJdbcTemplate")
|
@Qualifier("queryJdbcTemplate")
|
||||||
private final JdbcTemplate queryJdbcTemplate;
|
private final JdbcTemplate queryJdbcTemplate;
|
||||||
|
|
||||||
|
private final ShipImageService shipImageService;
|
||||||
|
|
||||||
// 캐시 서비스 (선택적 의존성 - 활성화 시에만 주입)
|
// 캐시 서비스 (선택적 의존성 - 활성화 시에만 주입)
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private VesselLatestPositionCache cache;
|
private VesselLatestPositionCache cache;
|
||||||
@ -32,11 +36,14 @@ public class VesselPositionService {
|
|||||||
* 조회 전략:
|
* 조회 전략:
|
||||||
* 1. 캐시 활성화 시: 메모리 캐시에서 조회 (빠른 응답)
|
* 1. 캐시 활성화 시: 메모리 캐시에서 조회 (빠른 응답)
|
||||||
* 2. 캐시 비활성화/실패 시: DB에서 직접 조회 (기존 로직)
|
* 2. 캐시 비활성화/실패 시: DB에서 직접 조회 (기존 로직)
|
||||||
|
* 3. 결과에 선박사진 정보 enrichment (인메모리 캐시 O(1) lookup)
|
||||||
*
|
*
|
||||||
* @param minutes 조회할 시간 범위 (분 단위)
|
* @param minutes 조회할 시간 범위 (분 단위)
|
||||||
* @return 필터링된 선박 위치 목록
|
* @return 필터링된 선박 위치 목록
|
||||||
*/
|
*/
|
||||||
public List<RecentVesselPositionDto> getRecentVesselPositions(int minutes) {
|
public List<RecentVesselPositionDto> getRecentVesselPositions(int minutes) {
|
||||||
|
List<RecentVesselPositionDto> results;
|
||||||
|
|
||||||
// 캐시 우선 조회
|
// 캐시 우선 조회
|
||||||
if (cache != null) {
|
if (cache != null) {
|
||||||
try {
|
try {
|
||||||
@ -44,17 +51,41 @@ public class VesselPositionService {
|
|||||||
|
|
||||||
if (!cachedResult.isEmpty()) {
|
if (!cachedResult.isEmpty()) {
|
||||||
log.debug("Cache hit: returned {} vessels for minutes={}", cachedResult.size(), minutes);
|
log.debug("Cache hit: returned {} vessels for minutes={}", cachedResult.size(), minutes);
|
||||||
return cachedResult;
|
results = cachedResult;
|
||||||
} else {
|
} else {
|
||||||
log.debug("Cache returned empty result, falling back to DB query");
|
log.debug("Cache returned empty result, falling back to DB query");
|
||||||
|
results = getRecentVesselPositionsFromDB(minutes);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Cache query failed, falling back to DB: {}", e.getMessage());
|
log.warn("Cache query failed, falling back to DB: {}", e.getMessage());
|
||||||
|
results = getRecentVesselPositionsFromDB(minutes);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
results = getRecentVesselPositionsFromDB(minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 캐시 미사용 또는 실패 시 DB 조회 (기존 로직)
|
// 선박사진 정보 enrichment
|
||||||
return getRecentVesselPositionsFromDB(minutes);
|
enrichWithShipImages(results);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박사진 인메모리 캐시에서 imo 기반 O(1) lookup → shipImagePath, shipImageCount 설정
|
||||||
|
*/
|
||||||
|
private void enrichWithShipImages(List<RecentVesselPositionDto> positions) {
|
||||||
|
int enriched = 0;
|
||||||
|
for (RecentVesselPositionDto pos : positions) {
|
||||||
|
ShipImageSummary summary = shipImageService.getImageSummary(pos.getImo());
|
||||||
|
if (summary != null) {
|
||||||
|
pos.setShipImagePath(summary.thumbnailPath());
|
||||||
|
pos.setShipImageCount(summary.imageCount());
|
||||||
|
enriched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enriched > 0) {
|
||||||
|
log.debug("ShipImage enrichment: {}/{} vessels", enriched, positions.size());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -105,7 +136,7 @@ public class VesselPositionService {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class VesselPositionRowMapper implements RowMapper<RecentVesselPositionDto> {
|
private static class VesselPositionRowMapper implements RowMapper<RecentVesselPositionDto> {
|
||||||
@Override
|
@Override
|
||||||
public RecentVesselPositionDto mapRow(ResultSet rs, int rowNum) throws SQLException {
|
public RecentVesselPositionDto mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||||
@ -137,4 +168,4 @@ public class VesselPositionService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user