feat(backend): 그룹 폴리곤 API — 목록/상세/히스토리 엔드포인트
- GroupPolygonController: GET /api/vessel-analysis/groups (목록, 상세, 히스토리) - GroupPolygonService: JdbcTemplate + ST_AsGeoJSON + Caffeine 5분 캐시 - GroupPolygonDto: GeoJSON polygon + members JSONB 응답 구조 - CacheConfig: GROUP_POLYGONS 캐시 키 추가
This commit is contained in:
부모
2441e3068a
커밋
b0fafca8c9
@ -21,6 +21,7 @@ public class CacheConfig {
|
|||||||
public static final String SEISMIC = "seismic";
|
public static final String SEISMIC = "seismic";
|
||||||
public static final String PRESSURE = "pressure";
|
public static final String PRESSURE = "pressure";
|
||||||
public static final String VESSEL_ANALYSIS = "vessel-analysis";
|
public static final String VESSEL_ANALYSIS = "vessel-analysis";
|
||||||
|
public static final String GROUP_POLYGONS = "group-polygons";
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
@ -29,7 +30,8 @@ public class CacheConfig {
|
|||||||
OSINT_IRAN, OSINT_KOREA,
|
OSINT_IRAN, OSINT_KOREA,
|
||||||
SATELLITES,
|
SATELLITES,
|
||||||
SEISMIC, PRESSURE,
|
SEISMIC, PRESSURE,
|
||||||
VESSEL_ANALYSIS
|
VESSEL_ANALYSIS,
|
||||||
|
GROUP_POLYGONS
|
||||||
);
|
);
|
||||||
manager.setCaffeine(Caffeine.newBuilder()
|
manager.setCaffeine(Caffeine.newBuilder()
|
||||||
.expireAfterWrite(2, TimeUnit.DAYS)
|
.expireAfterWrite(2, TimeUnit.DAYS)
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
package gc.mda.kcg.domain.fleet;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/vessel-analysis/groups")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GroupPolygonController {
|
||||||
|
|
||||||
|
private final GroupPolygonService groupPolygonService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 그룹 폴리곤 목록 (최신 스냅샷, 5분 캐시)
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> getGroups() {
|
||||||
|
List<GroupPolygonDto> groups = groupPolygonService.getLatestGroups();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"count", groups.size(),
|
||||||
|
"items", groups
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 그룹 상세 (멤버, 면적, 폴리곤 생성 근거)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{groupKey}/detail")
|
||||||
|
public ResponseEntity<GroupPolygonDto> getGroupDetail(@PathVariable String groupKey) {
|
||||||
|
GroupPolygonDto detail = groupPolygonService.getGroupDetail(groupKey);
|
||||||
|
if (detail == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 그룹 히스토리 (시간별 폴리곤 변화)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{groupKey}/history")
|
||||||
|
public ResponseEntity<List<GroupPolygonDto>> getGroupHistory(
|
||||||
|
@PathVariable String groupKey,
|
||||||
|
@RequestParam(defaultValue = "24") int hours) {
|
||||||
|
List<GroupPolygonDto> history = groupPolygonService.getGroupHistory(groupKey, hours);
|
||||||
|
return ResponseEntity.ok(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package gc.mda.kcg.domain.fleet;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class GroupPolygonDto {
|
||||||
|
private String groupType;
|
||||||
|
private String groupKey;
|
||||||
|
private String groupLabel;
|
||||||
|
private String snapshotTime;
|
||||||
|
private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON)
|
||||||
|
private double centerLat;
|
||||||
|
private double centerLon;
|
||||||
|
private double areaSqNm;
|
||||||
|
private int memberCount;
|
||||||
|
private String zoneId;
|
||||||
|
private String zoneName;
|
||||||
|
private List<Map<String, Object>> members;
|
||||||
|
private String color;
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package gc.mda.kcg.domain.fleet;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import gc.mda.kcg.config.CacheConfig;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.cache.Cache;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GroupPolygonService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final CacheManager cacheManager;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private static final String LATEST_GROUPS_SQL = """
|
||||||
|
SELECT group_type, group_key, group_label, snapshot_time,
|
||||||
|
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||||
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
|
area_sq_nm, member_count, zone_id, zone_name, members, color
|
||||||
|
FROM kcg.group_polygon_snapshots
|
||||||
|
WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots)
|
||||||
|
ORDER BY group_type, member_count DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String GROUP_DETAIL_SQL = """
|
||||||
|
SELECT group_type, group_key, group_label, snapshot_time,
|
||||||
|
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||||
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
|
area_sq_nm, member_count, zone_id, zone_name, members, color
|
||||||
|
FROM kcg.group_polygon_snapshots
|
||||||
|
WHERE group_key = ?
|
||||||
|
ORDER BY snapshot_time DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String GROUP_HISTORY_SQL = """
|
||||||
|
SELECT group_type, group_key, group_label, snapshot_time,
|
||||||
|
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||||
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
|
area_sq_nm, member_count, zone_id, zone_name, members, color
|
||||||
|
FROM kcg.group_polygon_snapshots
|
||||||
|
WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL)
|
||||||
|
ORDER BY snapshot_time DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시).
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<GroupPolygonDto> getLatestGroups() {
|
||||||
|
Cache cache = cacheManager.getCache(CacheConfig.GROUP_POLYGONS);
|
||||||
|
if (cache != null) {
|
||||||
|
Cache.ValueWrapper wrapper = cache.get("data");
|
||||||
|
if (wrapper != null) {
|
||||||
|
return (List<GroupPolygonDto>) wrapper.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GroupPolygonDto> results = jdbcTemplate.query(LATEST_GROUPS_SQL, this::mapRow);
|
||||||
|
|
||||||
|
if (cache != null) {
|
||||||
|
cache.put("data", results);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 그룹의 최신 상세 정보.
|
||||||
|
*/
|
||||||
|
public GroupPolygonDto getGroupDetail(String groupKey) {
|
||||||
|
List<GroupPolygonDto> results = jdbcTemplate.query(GROUP_DETAIL_SQL, this::mapRow, groupKey);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 그룹의 시간별 히스토리.
|
||||||
|
*/
|
||||||
|
public List<GroupPolygonDto> getGroupHistory(String groupKey, int hours) {
|
||||||
|
return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupPolygonDto mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
Object polygonObj = null;
|
||||||
|
String polygonJson = rs.getString("polygon_geojson");
|
||||||
|
if (polygonJson != null) {
|
||||||
|
try {
|
||||||
|
polygonObj = objectMapper.readValue(polygonJson, new TypeReference<Map<String, Object>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse polygon GeoJSON: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> members = List.of();
|
||||||
|
String membersJson = rs.getString("members");
|
||||||
|
if (membersJson != null) {
|
||||||
|
try {
|
||||||
|
members = objectMapper.readValue(membersJson, new TypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse members JSON: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GroupPolygonDto.builder()
|
||||||
|
.groupType(rs.getString("group_type"))
|
||||||
|
.groupKey(rs.getString("group_key"))
|
||||||
|
.groupLabel(rs.getString("group_label"))
|
||||||
|
.snapshotTime(rs.getString("snapshot_time"))
|
||||||
|
.polygon(polygonObj)
|
||||||
|
.centerLat(rs.getDouble("center_lat"))
|
||||||
|
.centerLon(rs.getDouble("center_lon"))
|
||||||
|
.areaSqNm(rs.getDouble("area_sq_nm"))
|
||||||
|
.memberCount(rs.getInt("member_count"))
|
||||||
|
.zoneId(rs.getString("zone_id"))
|
||||||
|
.zoneName(rs.getString("zone_name"))
|
||||||
|
.members(members)
|
||||||
|
.color(rs.getString("color"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user