Merge pull request 'feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가 + docs 정비' (#73) from feature/gear-identity-collision into develop
This commit is contained in:
커밋
e0af0e089d
@ -0,0 +1,64 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API.
|
||||||
|
*
|
||||||
|
* 경로: /api/analysis/gear-collisions
|
||||||
|
* - GET / 목록 (status/severity/name 필터, hours 윈도우)
|
||||||
|
* - GET /stats status/severity 집계
|
||||||
|
* - GET /{id} 단건 상세
|
||||||
|
* - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/analysis/gear-collisions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GearCollisionController {
|
||||||
|
|
||||||
|
private static final String RESOURCE = "detection:gear-collision";
|
||||||
|
|
||||||
|
private final GearIdentityCollisionService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public Page<GearCollisionResponse> list(
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String severity,
|
||||||
|
@RequestParam(required = false) String name,
|
||||||
|
@RequestParam(defaultValue = "48") int hours,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size
|
||||||
|
) {
|
||||||
|
return service.list(status, severity, name, hours,
|
||||||
|
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")))
|
||||||
|
.map(GearCollisionResponse::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) {
|
||||||
|
return service.stats(hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public GearCollisionResponse get(@PathVariable Long id) {
|
||||||
|
return GearCollisionResponse.from(service.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/resolve")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
|
||||||
|
public GearCollisionResponse resolve(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody GearCollisionResolveRequest req
|
||||||
|
) {
|
||||||
|
return GearCollisionResponse.from(service.resolve(id, req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 분류(해결) 액션 요청.
|
||||||
|
*
|
||||||
|
* action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN
|
||||||
|
* note : 선택 (운영자 메모)
|
||||||
|
*/
|
||||||
|
public record GearCollisionResolveRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN")
|
||||||
|
String action,
|
||||||
|
String note
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 조회 응답 DTO.
|
||||||
|
*/
|
||||||
|
public record GearCollisionResponse(
|
||||||
|
Long id,
|
||||||
|
String name,
|
||||||
|
String mmsiLo,
|
||||||
|
String mmsiHi,
|
||||||
|
String parentName,
|
||||||
|
Long parentVesselId,
|
||||||
|
OffsetDateTime firstSeenAt,
|
||||||
|
OffsetDateTime lastSeenAt,
|
||||||
|
Integer coexistenceCount,
|
||||||
|
Integer swapCount,
|
||||||
|
BigDecimal maxDistanceKm,
|
||||||
|
BigDecimal lastLatLo,
|
||||||
|
BigDecimal lastLonLo,
|
||||||
|
BigDecimal lastLatHi,
|
||||||
|
BigDecimal lastLonHi,
|
||||||
|
String severity,
|
||||||
|
String status,
|
||||||
|
String resolutionNote,
|
||||||
|
List<Map<String, Object>> evidence,
|
||||||
|
OffsetDateTime updatedAt
|
||||||
|
) {
|
||||||
|
public static GearCollisionResponse from(GearIdentityCollision e) {
|
||||||
|
return new GearCollisionResponse(
|
||||||
|
e.getId(),
|
||||||
|
e.getName(),
|
||||||
|
e.getMmsiLo(),
|
||||||
|
e.getMmsiHi(),
|
||||||
|
e.getParentName(),
|
||||||
|
e.getParentVesselId(),
|
||||||
|
e.getFirstSeenAt(),
|
||||||
|
e.getLastSeenAt(),
|
||||||
|
e.getCoexistenceCount(),
|
||||||
|
e.getSwapCount(),
|
||||||
|
e.getMaxDistanceKm(),
|
||||||
|
e.getLastLatLo(),
|
||||||
|
e.getLastLonLo(),
|
||||||
|
e.getLastLatHi(),
|
||||||
|
e.getLastLonHi(),
|
||||||
|
e.getSeverity(),
|
||||||
|
e.getStatus(),
|
||||||
|
e.getResolutionNote(),
|
||||||
|
e.getEvidence(),
|
||||||
|
e.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions status/severity 별 집계 응답.
|
||||||
|
*/
|
||||||
|
public record GearCollisionStatsResponse(
|
||||||
|
long total,
|
||||||
|
Map<String, Long> byStatus,
|
||||||
|
Map<String, Long> bySeverity,
|
||||||
|
int hours
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴).
|
||||||
|
*
|
||||||
|
* 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 내 동시 송출되는 공존 이력.
|
||||||
|
* prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 및 운영자 분류(status) 만 갱신.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "gear_identity_collisions", schema = "kcg")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class GearIdentityCollision {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 200)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "mmsi_lo", nullable = false, length = 20)
|
||||||
|
private String mmsiLo;
|
||||||
|
|
||||||
|
@Column(name = "mmsi_hi", nullable = false, length = 20)
|
||||||
|
private String mmsiHi;
|
||||||
|
|
||||||
|
@Column(name = "parent_name", length = 100)
|
||||||
|
private String parentName;
|
||||||
|
|
||||||
|
@Column(name = "parent_vessel_id")
|
||||||
|
private Long parentVesselId;
|
||||||
|
|
||||||
|
@Column(name = "first_seen_at", nullable = false)
|
||||||
|
private OffsetDateTime firstSeenAt;
|
||||||
|
|
||||||
|
@Column(name = "last_seen_at", nullable = false)
|
||||||
|
private OffsetDateTime lastSeenAt;
|
||||||
|
|
||||||
|
@Column(name = "coexistence_count", nullable = false)
|
||||||
|
private Integer coexistenceCount;
|
||||||
|
|
||||||
|
@Column(name = "swap_count", nullable = false)
|
||||||
|
private Integer swapCount;
|
||||||
|
|
||||||
|
@Column(name = "max_distance_km", precision = 8, scale = 2)
|
||||||
|
private BigDecimal maxDistanceKm;
|
||||||
|
|
||||||
|
@Column(name = "last_lat_lo", precision = 9, scale = 6)
|
||||||
|
private BigDecimal lastLatLo;
|
||||||
|
|
||||||
|
@Column(name = "last_lon_lo", precision = 10, scale = 6)
|
||||||
|
private BigDecimal lastLonLo;
|
||||||
|
|
||||||
|
@Column(name = "last_lat_hi", precision = 9, scale = 6)
|
||||||
|
private BigDecimal lastLatHi;
|
||||||
|
|
||||||
|
@Column(name = "last_lon_hi", precision = 10, scale = 6)
|
||||||
|
private BigDecimal lastLonHi;
|
||||||
|
|
||||||
|
@Column(name = "severity", nullable = false, length = 20)
|
||||||
|
private String severity;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 30)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "resolved_by")
|
||||||
|
private UUID resolvedBy;
|
||||||
|
|
||||||
|
@Column(name = "resolved_at")
|
||||||
|
private OffsetDateTime resolvedAt;
|
||||||
|
|
||||||
|
@Column(name = "resolution_note", columnDefinition = "text")
|
||||||
|
private String resolutionNote;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "evidence", columnDefinition = "jsonb")
|
||||||
|
private List<Map<String, Object>> evidence;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface GearIdentityCollisionRepository
|
||||||
|
extends JpaRepository<GearIdentityCollision, Long>,
|
||||||
|
JpaSpecificationExecutor<GearIdentityCollision> {
|
||||||
|
|
||||||
|
Page<GearIdentityCollision> findAllByLastSeenAtAfterOrderByLastSeenAtDesc(
|
||||||
|
OffsetDateTime after, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* status 별 카운트 집계 (hours 윈도우).
|
||||||
|
* 반환: [{status, count}, ...] — Object[] {String status, Long count}
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.status AS status, COUNT(g) AS cnt
|
||||||
|
FROM GearIdentityCollision g
|
||||||
|
WHERE g.lastSeenAt > :after
|
||||||
|
GROUP BY g.status
|
||||||
|
""")
|
||||||
|
List<Object[]> countByStatus(OffsetDateTime after);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* severity 별 카운트 집계 (hours 윈도우).
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.severity AS severity, COUNT(g) AS cnt
|
||||||
|
FROM GearIdentityCollision g
|
||||||
|
WHERE g.lastSeenAt > :after
|
||||||
|
GROUP BY g.severity
|
||||||
|
""")
|
||||||
|
List<Object[]> countBySeverity(OffsetDateTime after);
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import gc.mda.kcg.audit.annotation.Auditable;
|
||||||
|
import gc.mda.kcg.auth.AuthPrincipal;
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import jakarta.persistence.criteria.Predicate;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스.
|
||||||
|
*
|
||||||
|
* 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable} 로
|
||||||
|
* 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GearIdentityCollisionService {
|
||||||
|
|
||||||
|
private static final String RESOURCE_TYPE = "GEAR_COLLISION";
|
||||||
|
|
||||||
|
private final GearIdentityCollisionRepository repository;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<GearIdentityCollision> list(
|
||||||
|
String status,
|
||||||
|
String severity,
|
||||||
|
String name,
|
||||||
|
int hours,
|
||||||
|
Pageable pageable
|
||||||
|
) {
|
||||||
|
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
|
||||||
|
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
|
||||||
|
List<Predicate> preds = new ArrayList<>();
|
||||||
|
preds.add(cb.greaterThan(root.get("lastSeenAt"), after));
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
preds.add(cb.equal(root.get("status"), status));
|
||||||
|
}
|
||||||
|
if (severity != null && !severity.isBlank()) {
|
||||||
|
preds.add(cb.equal(root.get("severity"), severity));
|
||||||
|
}
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
preds.add(cb.like(root.get("name"), "%" + name + "%"));
|
||||||
|
}
|
||||||
|
return cb.and(preds.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
return repository.findAll(spec, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GearIdentityCollision get(Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GearCollisionStatsResponse stats(int hours) {
|
||||||
|
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
|
||||||
|
Map<String, Long> byStatus = new HashMap<>();
|
||||||
|
long total = 0;
|
||||||
|
for (Object[] row : repository.countByStatus(after)) {
|
||||||
|
String s = (String) row[0];
|
||||||
|
long c = ((Number) row[1]).longValue();
|
||||||
|
byStatus.put(s, c);
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
Map<String, Long> bySeverity = new HashMap<>();
|
||||||
|
for (Object[] row : repository.countBySeverity(after)) {
|
||||||
|
bySeverity.put((String) row[0], ((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE)
|
||||||
|
@Transactional
|
||||||
|
public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) {
|
||||||
|
GearIdentityCollision row = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
|
||||||
|
AuthPrincipal principal = currentPrincipal();
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
switch (req.action().toUpperCase()) {
|
||||||
|
case "REVIEWED" -> {
|
||||||
|
row.setStatus("REVIEWED");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "CONFIRMED_ILLEGAL" -> {
|
||||||
|
row.setStatus("CONFIRMED_ILLEGAL");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "FALSE_POSITIVE" -> {
|
||||||
|
row.setStatus("FALSE_POSITIVE");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "REOPEN" -> {
|
||||||
|
row.setStatus("OPEN");
|
||||||
|
row.setResolvedBy(null);
|
||||||
|
row.setResolvedAt(null);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
|
||||||
|
}
|
||||||
|
row.setUpdatedAt(now);
|
||||||
|
return repository.save(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthPrincipal currentPrincipal() {
|
||||||
|
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
|
||||||
|
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
|
||||||
|
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. 충돌 이력 테이블
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
|
||||||
|
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
|
||||||
|
mmsi_hi VARCHAR(20) NOT NULL,
|
||||||
|
parent_name VARCHAR(100),
|
||||||
|
parent_vessel_id BIGINT, -- fleet_vessels.id
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
|
||||||
|
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
|
||||||
|
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
|
||||||
|
last_lat_lo NUMERIC(9,6),
|
||||||
|
last_lon_lo NUMERIC(10,6),
|
||||||
|
last_lat_hi NUMERIC(9,6),
|
||||||
|
last_lon_hi NUMERIC(10,6),
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
|
||||||
|
resolved_by UUID,
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolution_note TEXT,
|
||||||
|
evidence JSONB, -- 최근 관측 요약
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
|
||||||
|
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_status
|
||||||
|
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_severity
|
||||||
|
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_parent
|
||||||
|
ON kcg.gear_identity_collisions(parent_vessel_id)
|
||||||
|
WHERE parent_vessel_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_name
|
||||||
|
ON kcg.gear_identity_collisions(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
|
||||||
|
ON kcg.gear_identity_collisions(last_seen_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.gear_identity_collisions IS
|
||||||
|
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree
|
||||||
|
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||||
|
url_path, label_key, component_key, nav_sort, labels)
|
||||||
|
VALUES
|
||||||
|
('detection:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
|
||||||
|
'/gear-collision', 'nav.gearCollision',
|
||||||
|
'features/detection/GearCollisionDetection', 950,
|
||||||
|
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. 권한 부여
|
||||||
|
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
|
||||||
|
-- OPERATOR: READ + UPDATE (분류 액션)
|
||||||
|
-- VIEWER/ANALYST/FIELD: READ
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'OPERATOR'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -50,6 +50,7 @@ src/
|
|||||||
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
|
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
|
||||||
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
|
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
|
||||||
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
|
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
|
||||||
|
│ │ # 2026-04-17: common.json 에 aria(36)/error(7)/dialog(4)/success(2)/message(5) 네임스페이스 추가
|
||||||
│ └── theme/ # tokens, colors, variants (CVA)
|
│ └── theme/ # tokens, colors, variants (CVA)
|
||||||
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
|
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
|
||||||
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
|
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
|
||||||
@ -89,20 +90,28 @@ src/
|
|||||||
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
|
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
|
||||||
│ └── index.ts # 배럴 export
|
│ └── index.ts # 배럴 export
|
||||||
│
|
│
|
||||||
├── shared/components/ # 공유 UI 컴포넌트
|
├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
|
||||||
│ ├── ui/
|
│ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
|
||||||
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
|
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
|
||||||
│ │ └── badge.tsx # Badge(CVA intent/size)
|
│ │ ├── badge.tsx # Badge(CVA intent 8종 × size 4단계, LEGACY_MAP 변형 호환)
|
||||||
|
│ │ ├── button.tsx # Button (variant 5종 × size 3단계, icon/trailingIcon prop)
|
||||||
|
│ │ ├── input.tsx # Input (size/state, forwardRef)
|
||||||
|
│ │ ├── select.tsx # Select (aria-label|aria-labelledby|title TS union 강제)
|
||||||
|
│ │ ├── textarea.tsx # Textarea
|
||||||
|
│ │ ├── checkbox.tsx # Checkbox (native input 래퍼)
|
||||||
|
│ │ ├── radio.tsx # Radio
|
||||||
|
│ │ └── tabs.tsx # TabBar + TabButton (underline/pill/segmented 3 variant)
|
||||||
|
│ ├── layout/ # PageContainer / PageHeader / Section (표준 페이지 루트)
|
||||||
│ └── common/
|
│ └── common/
|
||||||
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
|
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
|
||||||
│ ├── Pagination.tsx # 페이지네이션
|
│ ├── Pagination.tsx # 페이지네이션
|
||||||
│ ├── SearchInput.tsx # 검색 입력
|
│ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
|
||||||
│ ├── ExcelExport.tsx # 엑셀 다운로드
|
│ ├── ExcelExport.tsx # 엑셀 다운로드
|
||||||
│ ├── FileUpload.tsx # 파일 업로드
|
│ ├── FileUpload.tsx # 파일 업로드
|
||||||
│ ├── PageToolbar.tsx # 페이지 상단 툴바
|
│ ├── PageToolbar.tsx # 페이지 상단 툴바
|
||||||
│ ├── PrintButton.tsx # 인쇄 버튼
|
│ ├── PrintButton.tsx # 인쇄 버튼
|
||||||
│ ├── SaveButton.tsx # 저장 버튼
|
│ ├── SaveButton.tsx # 저장 버튼
|
||||||
│ └── NotificationBanner.tsx # 알림 배너
|
│ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
|
||||||
│
|
│
|
||||||
├── features/ # 13 도메인 그룹 (31 페이지)
|
├── features/ # 13 도메인 그룹 (31 페이지)
|
||||||
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
|
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
|
||||||
|
|||||||
@ -1,252 +0,0 @@
|
|||||||
# Mock 데이터 공유 현황 분석 및 통합 결과
|
|
||||||
|
|
||||||
> 최초 작성일: 2026-04-06
|
|
||||||
> 마지막 업데이트: 2026-04-06
|
|
||||||
> 대상: `kcg-ai-monitoring` 프론트엔드 코드베이스 전체 (31개 페이지)
|
|
||||||
> 상태: **통합 완료**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 선박 데이터 교차참조
|
|
||||||
|
|
||||||
현재 동일한 선박 데이터가 여러 컴포넌트에 독립적으로 하드코딩되어 있다. 각 파일마다 동일 선박의 속성(위험도, 위치, 상태 등)이 서로 다른 형식과 값으로 중복 정의되어 있어 데이터 일관성 문제가 발생한다.
|
|
||||||
|
|
||||||
| 선박명 | 등장 파일 수 | 파일 목록 |
|
|
||||||
|---|---|---|
|
|
||||||
| 鲁荣渔56555 | 7+ | Dashboard, MobileService, LiveMapView, MonitoringDashboard, EventList, EnforcementHistory, ChinaFishing |
|
|
||||||
| 浙甬渔60651 | 4 | Dashboard, LiveMapView, EventList, DarkVesselDetection |
|
|
||||||
| 冀黄港渔05001 | 6 | MobileService, LiveMapView, Dashboard, TransferDetection, EventList, GearDetection |
|
|
||||||
| 3001함 | 6+ | ShipAgent, MobileService, LiveMapView, Dashboard, PatrolRoute, FleetOptimization |
|
|
||||||
| 3009함 | 6+ | ShipAgent, MobileService, Dashboard, PatrolRoute, FleetOptimization, AIAlert |
|
|
||||||
| 미상선박-A | 5 | MobileService, Dashboard, LiveMapView, MonitoringDashboard, EventList |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 하나의 선박이 평균 5~7개 파일에 중복 정의됨
|
|
||||||
- 선박 속성(이름, MMSI, 위치, 위험도, 상태)이 파일마다 미세하게 다를 수 있음
|
|
||||||
- 새 선박 추가/수정 시 모든 관련 파일을 일일이 찾아 수정해야 함
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 위험도 스케일 불일치
|
|
||||||
|
|
||||||
동일한 선박의 위험도가 페이지마다 서로 다른 스케일로 표현되고 있다.
|
|
||||||
|
|
||||||
| 선박명 | Dashboard (risk) | DarkVesselDetection (risk) | MonitoringDashboard |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 鲁荣渔56555 | **0.96** (0~1 스케일) | - | **CRITICAL** (레벨 문자열) |
|
|
||||||
| 浙甬渔60651 | **0.85** (0~1 스케일) | **94** (0~100 정수) | - |
|
|
||||||
| 미상선박-A | **0.94** (0~1 스케일) | **96** (0~100 정수) | - |
|
|
||||||
|
|
||||||
### 원인 분석
|
|
||||||
- Dashboard는 `risk: 0.96` 형식 (0~1 소수)
|
|
||||||
- DarkVesselDetection은 `risk: 96` 형식 (0~100 정수)
|
|
||||||
- MonitoringDashboard는 `'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'` 레벨 문자열
|
|
||||||
- LiveMapView는 `risk: 0.94` 형식 (0~1 소수)
|
|
||||||
- EventList는 레벨 문자열 (`AlertLevel`)
|
|
||||||
|
|
||||||
### 통합 방안
|
|
||||||
위험도를 **0~100 정수** 스케일로 통일하되, 레벨 문자열은 구간별 자동 매핑 유틸로 변환한다.
|
|
||||||
|
|
||||||
```
|
|
||||||
0~30: LOW | 31~60: MEDIUM | 61~85: HIGH | 86~100: CRITICAL
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. KPI 수치 중복
|
|
||||||
|
|
||||||
Dashboard와 MonitoringDashboard가 **완전히 동일한 KPI 수치**를 독립적으로 정의하고 있다.
|
|
||||||
|
|
||||||
| 지표 | Dashboard `KPI_DATA` | MonitoringDashboard `KPI` |
|
|
||||||
|---|---|---|
|
|
||||||
| 실시간 탐지 | 47 | 47 |
|
|
||||||
| EEZ 침범 | 18 | 18 |
|
|
||||||
| 다크베셀 | 12 | 12 |
|
|
||||||
| 불법환적 의심 | 8 | 8 |
|
|
||||||
| 추적 중 | 15 | 15 |
|
|
||||||
| 나포/검문(금일 단속) | 3 | 3 |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 6개 KPI 수치가 두 파일에 100% 동일하게 하드코딩
|
|
||||||
- 수치 변경 시 양쪽 모두 수정해야 함
|
|
||||||
- Dashboard에는 `prev` 필드(전일 비교)가 추가로 있으나, Monitoring에는 없음
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 이벤트 타임라인 중복
|
|
||||||
|
|
||||||
08:47~06:12 시계열 이벤트가 최소 4개 파일에 각각 정의되어 있다.
|
|
||||||
|
|
||||||
| 시각 | Dashboard | Monitoring | MobileService | EventList |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 08:47 | EEZ 침범 (鲁荣渔56555) | EEZ 침범 (鲁荣渔56555 외 2척) | [긴급] EEZ 침범 탐지 | EVT-0001 EEZ 침범 |
|
|
||||||
| 08:32 | 다크베셀 출현 | 다크베셀 출현 | 다크베셀 출현 | EVT-0002 다크베셀 |
|
|
||||||
| 08:15 | 선단 밀집 경보 | 선단 밀집 경보 | - | EVT-0003 선단밀집 |
|
|
||||||
| 07:58 | 불법환적 의심 | 불법환적 의심 | 환적 의심 | EVT-0004 불법환적 |
|
|
||||||
| 07:41 | MMSI 변조 탐지 | MMSI 변조 탐지 | - | EVT-0005 MMSI 변조 |
|
|
||||||
| 07:23 | 함정 검문 완료 | 함정 검문 완료 | - | EVT-0006 검문 완료 |
|
|
||||||
| 06:12 | 속력 이상 탐지 | - | - | EVT-0010 속력 이상 |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 동일 이벤트의 description이 파일마다 미세하게 다름 (예: "鲁荣渔56555" vs "鲁荣渔56555 외 2척")
|
|
||||||
- EventList에는 ID가 있으나(EVT-xxxx), 다른 파일에는 없음
|
|
||||||
- Dashboard에는 10개, Monitoring에는 6개, EventList에는 15개로 **건수도 불일치**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 환적 데이터 100% 중복
|
|
||||||
|
|
||||||
`TransferDetection.tsx`와 `ChinaFishing.tsx`에 **TR-001~TR-003 환적 데이터가 완전히 동일**하게 정의되어 있다.
|
|
||||||
|
|
||||||
```
|
|
||||||
TransferDetection.tsx:
|
|
||||||
const transferData = [
|
|
||||||
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
|
|
||||||
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
|
|
||||||
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
|
|
||||||
];
|
|
||||||
|
|
||||||
ChinaFishing.tsx:
|
|
||||||
const TRANSFER_DATA = [
|
|
||||||
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
|
|
||||||
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
|
|
||||||
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 변수명만 다르고 (`transferData` vs `TRANSFER_DATA`) 데이터 구조와 값이 100% 동일
|
|
||||||
- 한쪽만 수정하면 다른 쪽과 불일치 발생
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 함정 상태 불일치
|
|
||||||
|
|
||||||
동일 함정의 상태가 페이지마다 모순되는 경우가 확인되었다.
|
|
||||||
|
|
||||||
| 함정 | ShipAgent | Dashboard | PatrolRoute | FleetOptimization |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 5001함 | **오프라인** (`status: '오프라인'`) | **가용** (PATROL_SHIPS에 대기로 표시) | **가용** (`status: '가용'`) | **가용** (`status: '가용'`) |
|
|
||||||
| 3009함 | **온라인** (동기화 중) | **검문 중** | **출동중** | **출동중** |
|
|
||||||
| 1503함 | **미배포** | - | - | **정비중** |
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 5001함이 ShipAgent에서는 오프라인이지만, Dashboard/PatrolRoute/FleetOptimization에서는 가용으로 표시됨 -- **직접적 모순**
|
|
||||||
- 3009함의 상태가 "온라인", "검문 중", "출동중"으로 파일마다 다름
|
|
||||||
- 실제 운영 시 혼란을 초래할 수 있는 시나리오 불일치
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 현재 상태: 통합 완료
|
|
||||||
|
|
||||||
아래 분석에서 식별한 모든 중복/불일치 문제를 해소하기 위해, 7개 공유 Mock 모듈 + 7개 Zustand 스토어 체계로 통합이 **완료**되었다.
|
|
||||||
|
|
||||||
### 7.1 완료된 아키텍처: mock -> store -> page
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ src/data/mock/ (7개 공유 모듈) │
|
|
||||||
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
|
|
||||||
│ vessels │ patrols │ events │ kpi │ transfers │ gear │enforce-│
|
|
||||||
│ .ts │ .ts │ .ts │ .ts │ .ts │ .ts │ment.ts │
|
|
||||||
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
|
|
||||||
│ │ │ │ │ │ │
|
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ src/stores/ (7개 Zustand 스토어 + settingsStore) │
|
|
||||||
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
|
|
||||||
│ vessel │ patrol │ event │ kpi │ transfer │ gear │enforce-│
|
|
||||||
│ Store │ Store │ Store │ Store │ Store │ Store │mentStr │
|
|
||||||
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
|
|
||||||
│ │ │ │ │ │ │
|
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ src/features/*/ (페이지 컴포넌트) │
|
|
||||||
│ store.load() 호출 -> store에서 데이터 구독 -> 뷰 변환은 페이지 책임 │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 스토어별 소비 현황 (16개 페이지가 스토어 사용)
|
|
||||||
|
|
||||||
| 스토어 | 소비 페이지 |
|
|
||||||
|---|---|
|
|
||||||
| `useVesselStore` | Dashboard, LiveMapView, DarkVesselDetection, VesselDetail |
|
|
||||||
| `usePatrolStore` | Dashboard, PatrolRoute, FleetOptimization |
|
|
||||||
| `useEventStore` | Dashboard, MonitoringDashboard, LiveMapView, EventList, MobileService, AIAlert |
|
|
||||||
| `useKpiStore` | Dashboard, MonitoringDashboard, Statistics |
|
|
||||||
| `useTransferStore` | TransferDetection, ChinaFishing |
|
|
||||||
| `useGearStore` | GearDetection |
|
|
||||||
| `useEnforcementStore` | EnforcementPlan, EnforcementHistory |
|
|
||||||
|
|
||||||
### 7.3 페이지 전용 인라인 데이터 (미통합)
|
|
||||||
|
|
||||||
아래 페이지들은 도메인 특성상 공유 mock에 포함하지 않고 페이지 전용 인라인 데이터를 유지한다.
|
|
||||||
|
|
||||||
| 페이지 | 인라인 데이터 | 사유 |
|
|
||||||
|---|---|---|
|
|
||||||
| ChinaFishing | `COUNTERS_ROW1/2`, `VESSEL_LIST`, `MONTHLY_DATA`, `VTS_ITEMS` | 중국어선 전용 센서 카운터/통계 (다른 페이지에서 미사용) |
|
|
||||||
| VesselDetail | `VESSELS: VesselTrack[]` | 항적 데이터 구조가 `VesselData`와 다름 (주석으로 명시) |
|
|
||||||
| MLOpsPage | 실험/배포 데이터 | MLOps 전용 도메인 데이터 |
|
|
||||||
| MapControl | 훈련구역 데이터 | 해상사격 훈련구역 전용 |
|
|
||||||
| DataHub | 수신현황 데이터 | 데이터 허브 전용 모니터링 |
|
|
||||||
| AIModelManagement | 모델/규칙 데이터 | AI 모델 관리 전용 |
|
|
||||||
| AIAssistant | `SAMPLE_CONVERSATIONS` | 챗봇 샘플 대화 |
|
|
||||||
| LoginPage | `DEMO_ACCOUNTS` | 데모 인증 정보 |
|
|
||||||
| 기타 (AdminPanel, SystemConfig 등) | 각 페이지 전용 설정/관리 데이터 | 관리 도메인 특화 |
|
|
||||||
|
|
||||||
### 7.4 설계 원칙 (구현 완료)
|
|
||||||
|
|
||||||
1. **위험도 0~100 통일**: 모든 선박의 위험도를 0~100 정수로 통일. 레벨 문자열은 유틸 함수로 변환.
|
|
||||||
2. **단일 원천(Single Source of Truth)**: 각 데이터는 하나의 mock 모듈에서만 정의하고, 스토어를 통해 접근.
|
|
||||||
3. **Lazy Loading**: 스토어의 `load()` 메서드가 최초 호출 시 `import()`로 mock 데이터를 동적 로딩 (loaded 플래그로 중복 방지).
|
|
||||||
4. **뷰 변환은 페이지 책임**: mock 모듈/스토어는 원본 데이터만 제공하고, 화면별 가공(필터, 정렬, 포맷)은 각 페이지에서 수행.
|
|
||||||
|
|
||||||
### 7.5 Mock 모듈 상세 (참고용)
|
|
||||||
|
|
||||||
참고: 초기 분석에서 계획했던 `areas.ts`는 최종 구현 시 `enforcement.ts`(단속 이력 데이터)로 대체되었다.
|
|
||||||
해역/구역 데이터는 RiskMap, MapControl 등 각 페이지에서 전용 데이터로 관리한다.
|
|
||||||
|
|
||||||
| # | 모듈 파일 | 스토어 | 내용 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `data/mock/vessels.ts` | `vesselStore` | 중국어선 + 한국어선 + 미상선박 마스터 (`MOCK_VESSELS`, `MOCK_SUSPECTS`) |
|
|
||||||
| 2 | `data/mock/patrols.ts` | `patrolStore` | 경비함정 마스터 + 경로/시나리오/커버리지 |
|
|
||||||
| 3 | `data/mock/events.ts` | `eventStore` | 이벤트 타임라인 + 알림 데이터 |
|
|
||||||
| 4 | `data/mock/kpi.ts` | `kpiStore` | KPI 수치 + 월별 추이 |
|
|
||||||
| 5 | `data/mock/transfers.ts` | `transferStore` | 환적 데이터 (TR-001~003) |
|
|
||||||
| 6 | `data/mock/gear.ts` | `gearStore` | 어구 데이터 (불법어구 목록) |
|
|
||||||
| 7 | `data/mock/enforcement.ts` | `enforcementStore` | 단속 이력 + 단속 계획 데이터 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 작업 완료 요약
|
|
||||||
|
|
||||||
| 모듈 | 상태 | 스토어 소비 페이지 수 |
|
|
||||||
|---|---|---|
|
|
||||||
| `vessels.ts` | **완료** | 4개 (useVesselStore) |
|
|
||||||
| `events.ts` | **완료** | 6개 (useEventStore) |
|
|
||||||
| `patrols.ts` | **완료** | 3개 (usePatrolStore) |
|
|
||||||
| `kpi.ts` | **완료** | 3개 (useKpiStore) |
|
|
||||||
| `transfers.ts` | **완료** | 2개 (useTransferStore) |
|
|
||||||
| `gear.ts` | **완료** | 1개 (useGearStore) |
|
|
||||||
| `enforcement.ts` | **완료** | 2개 (useEnforcementStore) |
|
|
||||||
|
|
||||||
### 실제 작업 결과
|
|
||||||
- Mock 모듈 생성: 7개 파일 (`src/data/mock/`)
|
|
||||||
- Zustand 스토어 생성: 7개 + 1개 설정용 (`src/stores/`)
|
|
||||||
- 기존 페이지 리팩토링: 16개 페이지에서 스토어 소비로 전환
|
|
||||||
- 나머지 15개 페이지: 도메인 특화 인라인 데이터 유지 (공유 필요성 없음)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 결론
|
|
||||||
|
|
||||||
위 1~6절에서 분석한 6개의 심각한 중복/불일치 문제(위험도 스케일, 함정 상태 모순, KPI 중복, 이벤트 불일치, 환적 100% 중복, 선박 교차참조)는 **7개 공유 mock 모듈 + 7개 Zustand 스토어** 도입으로 모두 해소되었다.
|
|
||||||
|
|
||||||
달성한 효과:
|
|
||||||
- **데이터 일관성**: Single Source of Truth로 불일치 원천 차단
|
|
||||||
- **유지보수성**: 데이터 변경 시 mock 모듈 1곳만 수정
|
|
||||||
- **확장성**: 신규 페이지 추가 시 기존 store import로 즉시 사용
|
|
||||||
- **코드 품질**: 중복 인라인 데이터 제거, 16개 페이지가 스토어 기반으로 전환
|
|
||||||
- **성능**: Zustand lazy loading으로 최초 접근 시에만 mock 데이터 로딩
|
|
||||||
|
|
||||||
1~6절의 분석 내용은 통합 전 문제 식별 기록으로 보존한다.
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
# KCG AI Monitoring - 다음 단계 리팩토링 TODO
|
|
||||||
|
|
||||||
> 프론트엔드 UI 스캐폴딩 + 기반 인프라(상태관리, 지도 GPU, mock 데이터, CVA) 완료 상태. 백엔드 연동 및 운영 품질 확보를 위해 남은 항목을 순차적으로 진행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ✅ 상태관리 도입 (Zustand 5.0) — COMPLETED
|
|
||||||
|
|
||||||
`zustand` 5.0.12 설치, `src/stores/`에 8개 독립 스토어 구현 완료.
|
|
||||||
|
|
||||||
- `vesselStore` — 선박 목록, 선택, 필터
|
|
||||||
- `patrolStore` — 순찰 경로/함정
|
|
||||||
- `eventStore` — 탐지/경보 이벤트
|
|
||||||
- `kpiStore` — KPI 메트릭, 추세
|
|
||||||
- `transferStore` — 전재(환적)
|
|
||||||
- `gearStore` — 어구 탐지
|
|
||||||
- `enforcementStore` — 단속 이력
|
|
||||||
- `settingsStore` — theme/language + localStorage 동기화, 지도 타일 자동 전환
|
|
||||||
|
|
||||||
> `AuthContext`는 유지 (인증은 Context API가 적합, 마이그레이션 불필요로 결정)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. API 서비스 계층 (Axios 1.14) — 구조 완성, 실제 연동 대기
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- `src/services/`에 7개 서비스 모듈 구현 (api, vessel, event, patrol, kpi, ws, index)
|
|
||||||
- `api.ts`: fetch 래퍼 (`apiGet`, `apiPost`) — 향후 Axios 교체 예정
|
|
||||||
- 각 서비스가 `data/mock/` 모듈에서 mock 데이터 반환 (실제 HTTP 호출 0건)
|
|
||||||
- `ws.ts`: STOMP WebSocket 스텁 존재, 미구현
|
|
||||||
|
|
||||||
### 남은 작업
|
|
||||||
- [ ] `axios` 1.14 설치 → `api.ts`의 fetch 래퍼를 Axios 인스턴스로 교체
|
|
||||||
- [ ] Axios 인터셉터:
|
|
||||||
- Request: Authorization 헤더 자동 주입
|
|
||||||
- Response: 401 → 로그인 리다이렉트, 500 → 에러 토스트
|
|
||||||
- [ ] `@tanstack/react-query` 5.x 설치 → TanStack Query Provider 추가
|
|
||||||
- [ ] 각 서비스의 mock 반환을 실제 API 호출로 교체
|
|
||||||
- [ ] 로딩 스켈레톤, 에러 바운더리 공통 컴포넌트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 실시간 인프라 (STOMP.js + SockJS) — 스텁 구조만 존재
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- `services/ws.ts`에 `connectWs` 스텁 함수 존재 (인터페이스 정의 완료)
|
|
||||||
- STOMP.js, SockJS 미설치 — 실제 WebSocket 연결 없음
|
|
||||||
- `useStoreLayerSync` hook으로 store→지도 실시간 파이프라인 준비 완료
|
|
||||||
|
|
||||||
### 남은 작업
|
|
||||||
- [ ] `@stomp/stompjs` + `sockjs-client` 설치
|
|
||||||
- [ ] `ws.ts` 스텁을 실제 STOMP 클라이언트로 구현
|
|
||||||
- [ ] 구독 채널 설계:
|
|
||||||
- `/topic/ais-positions` — 실시간 AIS 위치
|
|
||||||
- `/topic/alerts` — 경보/이벤트
|
|
||||||
- `/topic/detections` — 탐지 결과
|
|
||||||
- `/user/queue/notifications` — 개인 알림
|
|
||||||
- [ ] 재연결 로직 (지수 백오프)
|
|
||||||
- [ ] store → `useStoreLayerSync` → 지도 마커 실시간 업데이트 연결
|
|
||||||
- [ ] `eventStore`와 연동하여 알림 배너/뱃지 카운트 업데이트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ✅ 고급 지도 레이어 (deck.gl 9.2) — COMPLETED
|
|
||||||
|
|
||||||
`deck.gl` 9.2.11 + `@deck.gl/mapbox` 설치, MapLibre + deck.gl 인터리브 아키텍처 구현 완료.
|
|
||||||
|
|
||||||
- **BaseMap**: `forwardRef` + `memo`, `MapboxOverlay`를 `useImperativeHandle`로 외부 노출
|
|
||||||
- **useMapLayers**: RAF 배치 레이어 업데이트, React 리렌더 0회
|
|
||||||
- **useStoreLayerSync**: Zustand store.subscribe → RAF → overlay.setProps (React 우회)
|
|
||||||
- **STATIC_LAYERS**: EEZ + NLL PathLayer 싱글턴 (GPU 1회 업로드)
|
|
||||||
- **createMarkerLayer**: ScatterplotLayer + transitions 보간 + DataFilterExtension
|
|
||||||
- **createRadiusLayer**: 반경 원 표시용 ScatterplotLayer
|
|
||||||
- 레거시 GeoJSON 레이어(`boundaries.ts`)는 하위 호환으로 유지
|
|
||||||
|
|
||||||
> 성능 목표 40만척+ GPU 렌더링 달성. TripsLayer/HexagonLayer/IconLayer는 실데이터 확보 후 추가 예정.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. ✅ 더미 데이터 통합 — COMPLETED
|
|
||||||
|
|
||||||
`src/data/mock/`에 7개 공유 mock 모듈 구현 완료. TypeScript 인터페이스 정의 포함.
|
|
||||||
|
|
||||||
```
|
|
||||||
data/mock/
|
|
||||||
├── vessels.ts # VesselData — 선박 목록 (한국, 중국, 경비함)
|
|
||||||
├── events.ts # EventRecord, AlertRecord — 탐지/단속 이벤트
|
|
||||||
├── transfers.ts # 전재(환적) 데이터
|
|
||||||
├── patrols.ts # PatrolShip — 순찰 경로/함정
|
|
||||||
├── gear.ts # 어구 탐지 데이터
|
|
||||||
├── kpi.ts # KpiMetric, MonthlyTrend, ViolationType
|
|
||||||
└── enforcement.ts # 단속 이력 데이터
|
|
||||||
```
|
|
||||||
|
|
||||||
- `services/` 계층이 mock 모듈을 import하여 반환 → 향후 API 교체 시 서비스만 수정
|
|
||||||
- 인터페이스가 API 응답 타입 계약 역할 수행
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. i18n 실적용 — 구조 완성, 내부 텍스트 미적용
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- 10 네임스페이스 리소스 완비: common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth
|
|
||||||
- ko/en 각 10파일 (총 20 JSON)
|
|
||||||
- `settingsStore.toggleLanguage()` + `localStorage` 동기화 구현 완료
|
|
||||||
- **적용 완료**: MainLayout 사이드바 메뉴명, 24개 페이지 제목, LoginPage
|
|
||||||
- **미적용**: 각 페이지 내부 텍스트 (카드 레이블, 테이블 헤더, 상태 텍스트 등) — 대부분 한국어 하드코딩 잔존
|
|
||||||
|
|
||||||
### 남은 작업
|
|
||||||
- [ ] 각 feature 페이지 내부 텍스트를 `useTranslation('namespace')` + `t()` 로 교체
|
|
||||||
- [ ] 날짜/숫자 포맷 로컬라이즈 (`Intl.DateTimeFormat`, `Intl.NumberFormat`)
|
|
||||||
- [ ] 누락 키 감지 자동화 (i18next missing key handler 또는 lint 규칙)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. ✅ Tailwind 공통 스타일 모듈화 (CVA) — COMPLETED
|
|
||||||
|
|
||||||
`class-variance-authority` 0.7.1 설치, `src/lib/theme/variants.ts`에 3개 CVA 변형 구현 완료.
|
|
||||||
|
|
||||||
- **cardVariants**: default / elevated / inner / transparent — CSS 변수 기반 테마 반응
|
|
||||||
- **badgeVariants**: 8 intent (critical~cyan) x 4 size (xs~lg) — 150회+ 반복 패턴 통합
|
|
||||||
- **statusDotVariants**: 4 status (online/warning/danger/offline) x 3 size (sm/md/lg)
|
|
||||||
- `shared/components/ui/card.tsx`, `badge.tsx`에 CVA 적용 완료
|
|
||||||
- CSS 변수(`surface-raised`, `surface-overlay`, `border`) 참조로 Dark/Light 자동 반응
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 코드 스플리팅 — 미착수
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- **단일 번들 ~3.2MB** (모든 feature + deck.gl + MapLibre + ECharts 포함)
|
|
||||||
- `React.lazy` 미적용, 모든 31개 페이지가 동기 import
|
|
||||||
- 초기 로딩 시 사용하지 않는 페이지 코드까지 전부 다운로드
|
|
||||||
|
|
||||||
### 필요한 이유
|
|
||||||
- 초기 로딩 성능 개선 (FCP, LCP)
|
|
||||||
- 현장 모바일 환경 (LTE/3G)에서의 사용성 확보
|
|
||||||
- 번들 캐싱 효율 향상 (변경된 chunk만 재다운로드)
|
|
||||||
|
|
||||||
### 구현 계획
|
|
||||||
- [ ] `React.lazy` + `Suspense`로 feature 단위 동적 임포트:
|
|
||||||
```typescript
|
|
||||||
const Dashboard = lazy(() => import('@features/dashboard/Dashboard'));
|
|
||||||
const RiskMap = lazy(() => import('@features/risk-assessment/RiskMap'));
|
|
||||||
```
|
|
||||||
- [ ] `App.tsx` 라우트 전체를 lazy 컴포넌트로 교체
|
|
||||||
- [ ] 로딩 폴백 컴포넌트 (스켈레톤 또는 스피너) 공통화
|
|
||||||
- [ ] Vite `build.rollupOptions.output.manualChunks` 설정:
|
|
||||||
```typescript
|
|
||||||
manualChunks: {
|
|
||||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
|
||||||
'vendor-map': ['maplibre-gl', 'deck.gl', '@deck.gl/mapbox'],
|
|
||||||
'vendor-chart': ['echarts'],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- [ ] 목표: 초기 번들 < 300KB (gzip), 각 feature chunk < 100KB
|
|
||||||
- [ ] `vite-plugin-compression`으로 gzip/brotli 사전 압축 검토
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Light 테마 하드코딩 정리
|
|
||||||
|
|
||||||
### 현재 상태
|
|
||||||
- Dark/Light 테마 전환 구조 완성 (CSS 변수 + `.light` 클래스 + settingsStore)
|
|
||||||
- 시맨틱 변수(`surface-raised`, `text-heading` 등) + CVA 변형은 정상 작동
|
|
||||||
- **문제**: 일부 alert/status 색상이 Tailwind 하드코딩 (`bg-red-500/20`, `text-red-400`, `border-red-500/30` 등)
|
|
||||||
- Dark에서는 자연스러우나, Light 전환 시 대비/가독성 부족
|
|
||||||
|
|
||||||
### 구현 계획
|
|
||||||
- [ ] 하드코딩 alert 색상을 CSS 변수 또는 CVA intent로 교체
|
|
||||||
- [ ] `badgeVariants`의 intent 색상도 CSS 변수 기반으로 전환 검토
|
|
||||||
- [ ] Light 모드 전용 대비 테스트 (WCAG AA 기준)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 우선순위 및 의존관계
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ 완료 ─────────────────────────────────────
|
|
||||||
[1. Zustand] [4. deck.gl] [5. mock 데이터] [7. CVA]
|
|
||||||
|
|
||||||
진행 중 / 남은 작업 ──────────────────────────
|
|
||||||
[6. i18n 내부 텍스트] ──┐
|
|
||||||
├──▶ [2. API 실제 연동] ──▶ [3. 실시간 STOMP]
|
|
||||||
[9. Light 테마 정리] ───┘
|
|
||||||
|
|
||||||
[8. 코드 스플리팅] ← 독립 작업, 언제든 착수 가능 (~3.2MB → 목표 <300KB)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 권장 진행 순서
|
|
||||||
|
|
||||||
1. **Phase A (품질)**: i18n 내부 텍스트 적용 (6) + Light 테마 하드코딩 정리 (9) + 코드 스플리팅 (8)
|
|
||||||
2. **Phase B (연동)**: Axios 설치 + API 실제 연동 (2)
|
|
||||||
3. **Phase C (실시간)**: STOMP.js + SockJS 실시간 인프라 (3)
|
|
||||||
@ -1,436 +0,0 @@
|
|||||||
# 페이지 역할표 및 업무 파이프라인
|
|
||||||
|
|
||||||
> 최초 작성일: 2026-04-06
|
|
||||||
> 마지막 업데이트: 2026-04-06
|
|
||||||
> 대상: `kcg-ai-monitoring` 프론트엔드 31개 페이지
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 공통 아키텍처
|
|
||||||
|
|
||||||
### 디렉토리 구조
|
|
||||||
|
|
||||||
모든 페이지는 `src/features/` 아래 도메인별 디렉토리에 배치되어 있다.
|
|
||||||
|
|
||||||
```
|
|
||||||
src/features/
|
|
||||||
admin/ AccessControl, AdminPanel, DataHub, NoticeManagement, SystemConfig
|
|
||||||
ai-operations/ AIAssistant, AIModelManagement, MLOpsPage
|
|
||||||
auth/ LoginPage
|
|
||||||
dashboard/ Dashboard
|
|
||||||
detection/ ChinaFishing, DarkVesselDetection, GearDetection, GearIdentification
|
|
||||||
enforcement/ EnforcementHistory, EventList
|
|
||||||
field-ops/ AIAlert, MobileService, ShipAgent
|
|
||||||
monitoring/ MonitoringDashboard
|
|
||||||
patrol/ FleetOptimization, PatrolRoute
|
|
||||||
risk-assessment/ EnforcementPlan, RiskMap
|
|
||||||
statistics/ ExternalService, ReportManagement, Statistics
|
|
||||||
surveillance/ LiveMapView, MapControl
|
|
||||||
vessel/ TransferDetection, VesselDetail
|
|
||||||
```
|
|
||||||
|
|
||||||
### 데이터 흐름
|
|
||||||
|
|
||||||
모든 공유 데이터는 **mock -> store -> page** 패턴으로 흐른다.
|
|
||||||
|
|
||||||
```
|
|
||||||
src/data/mock/*.ts --> src/stores/*Store.ts --> src/features/*/*.tsx
|
|
||||||
(7개 공유 모듈) (7개 Zustand 스토어) (16개 페이지가 스토어 소비)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 스토어는 `load()` 호출 시 `import()`로 mock 데이터를 lazy loading
|
|
||||||
- 도메인 특화 데이터는 페이지 내 인라인으로 유지 (MLOps, MapControl, DataHub 등)
|
|
||||||
- 상세 매핑은 `docs/data-sharing-analysis.md` 참조
|
|
||||||
|
|
||||||
### 지도 렌더링
|
|
||||||
|
|
||||||
지도가 필요한 11개 페이지는 공통 `src/lib/map/` 인프라를 사용한다.
|
|
||||||
|
|
||||||
- **deck.gl** 기반 렌더링 (`BaseMap.tsx`)
|
|
||||||
- **`useMapLayers`** 훅: 페이지별 동적 레이어 구성
|
|
||||||
- **`STATIC_LAYERS`**: EEZ/KDLZ 등 정적 레이어를 상수로 분리하여 zero rerender 보장
|
|
||||||
- 사용 페이지: Dashboard, LiveMapView, MapControl, EnforcementPlan, PatrolRoute, FleetOptimization, GearDetection, DarkVesselDetection, RiskMap, VesselDetail, MobileService
|
|
||||||
|
|
||||||
### 다국어 (i18n)
|
|
||||||
|
|
||||||
- `react-i18next` 기반, 24개 페이지 + MainLayout + LoginPage에 i18n 적용
|
|
||||||
- 지원 언어: 한국어 (ko), 영어 (en)
|
|
||||||
- 페이지 타이틀, 주요 UI 라벨이 번역 키로 관리됨
|
|
||||||
|
|
||||||
### 테마
|
|
||||||
|
|
||||||
- `settingsStore`에서 dark/light 테마 전환 지원
|
|
||||||
- 기본값: dark (해양 감시 시스템 특성상)
|
|
||||||
- `localStorage`에 선택 유지, CSS 클래스 토글 방식
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 31개 페이지 역할표
|
|
||||||
|
|
||||||
### 1.1 인증/관리 (4개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-01 | LoginPage | `/login` | 전체 | SSO/GPKI/비밀번호 인증, 5회 실패 잠금 | ID/PW, 인증 방식 선택 | 세션 발급, 역할 부여 | - | 모든 페이지 (인증 게이트) |
|
|
||||||
| SFR-01 | AccessControl | `/access-control` | 관리자 | RBAC 권한 관리, 감사 로그 | 역할/사용자/권한 설정 | 권한 변경, 감사 기록 | LoginPage | 전체 시스템 접근 제어 |
|
|
||||||
| SFR-02 | SystemConfig | `/system-config` | 관리자 | 공통코드 기준정보 관리 (해역52/어종578/어업59/선박186) | 코드 검색/필터 | 코드 조회, 설정 변경 | AccessControl | 탐지/분석 엔진 기준데이터 |
|
|
||||||
| SFR-02 | NoticeManagement | `/notices` | 관리자 | 시스템 공지(배너/팝업/토스트), 역할별 대상 설정 | 공지 작성, 기간/대상 설정 | 배너/팝업 노출 | AccessControl | 모든 페이지 (NotificationBanner) |
|
|
||||||
|
|
||||||
### 1.2 데이터 수집/연계 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-03 | DataHub | `/data-hub` | 관리자 | 통합데이터 허브 — 선박신호 수신 현황 히트맵, 연계 채널 모니터링 | 수신 소스 선택 | 수신률 조회, 연계 상태 확인 | 외부 센서 (VTS, AIS, V-PASS 등) | 탐지 파이프라인 전체 |
|
|
||||||
|
|
||||||
### 1.3 AI 모델/운영 (3개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-04 | AIModelManagement | `/ai-model` | 분석관 | 모델 레지스트리, 탐지 규칙, 피처 엔지니어링, 학습 파이프라인, 7대 탐지엔진 | 모델 버전/규칙/피처 설정 | 모델 배포, 성능 리포트 | DataHub (학습 데이터) | DarkVessel, GearDetection, TransferDetection 등 탐지 엔진 |
|
|
||||||
| SFR-18/19 | MLOpsPage | `/mlops` | 분석관/관리자 | MLOps/LLMOps 운영 대시보드 (실험, 배포, API Playground, LLM 테스트) | 실험 템플릿, HPS 설정 | 실험 결과, 모델 배포 | AIModelManagement | AIAssistant, 탐지 엔진 |
|
|
||||||
| SFR-20 | AIAssistant | `/ai-assistant` | 상황실/분석관 | 자연어 Q&A 의사결정 지원 (법령 조회, 대응 절차 안내) | 자연어 질의 | 답변 + 법령 참조 | MLOpsPage (LLM 모델) | 작전 의사결정 |
|
|
||||||
|
|
||||||
### 1.4 탐지 (4개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-09 | DarkVesselDetection | `/dark-vessel` | 분석관 | AIS 조작/위장/Dark Vessel 패턴 탐지 (6가지 패턴), 지도+테이블 | AIS 데이터 스트림 | 의심 선박 목록, 위험도, 라벨 분류 | DataHub (AIS/레이더) | RiskMap, LiveMapView, EventList |
|
|
||||||
| SFR-10 | GearDetection | `/gear-detection` | 분석관 | 불법 어망/어구 탐지 및 관리, 허가 상태 판정 | 어구 센서/영상 | 어구 목록, 불법 판정 결과 | DataHub (센서) | RiskMap, EnforcementPlan |
|
|
||||||
| - | GearIdentification | `features/detection/` | 분석관 | 어구 국적 판별 (중국/한국/불확실), GB/T 5147 기준 | 어구 물리적 특성 입력 | 판별 결과 (국적, 신뢰도, 경보등급) | GearDetection | EnforcementHistory |
|
|
||||||
| - | ChinaFishing | `/china-fishing` | 분석관/상황실 | 중국어선 통합 감시 (센서 카운터, 특이운항, 월별 통계, 환적 탐지, VTS 연계) | 센서 데이터 융합 | 감시 현황, 환적 의심 목록 | DataHub, DarkVessel | RiskMap, EnforcementPlan |
|
|
||||||
|
|
||||||
### 1.5 환적 탐지 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | TransferDetection | `features/vessel/` | 분석관 | 선박 간 근접 접촉 및 환적 의심 행위 분석 (거리/시간/속도 기준) | AIS 궤적 분석 | 환적 이벤트 목록, 의심도 점수 | DataHub, DarkVessel | EventList, EnforcementPlan |
|
|
||||||
|
|
||||||
### 1.6 위험도 평가/계획 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-05 | RiskMap | `/risk-map` | 분석관/상황실 | 격자 기반 불법조업 위험도 지도 + MTIS 해양사고 통계 연계 | 탐지 결과, 사고 통계 | 히트맵, 해역별 위험도, 사고 통계 차트 | DarkVessel, GearDetection, ChinaFishing | EnforcementPlan, PatrolRoute |
|
|
||||||
| SFR-06 | EnforcementPlan | `/enforcement-plan` | 상황실 | 단속 계획 수립, 경보 연계, 우선지역 예보 | 위험도 데이터, 가용 함정 | 단속 계획 테이블, 지도 표시 | RiskMap | PatrolRoute, FleetOptimization |
|
|
||||||
|
|
||||||
### 1.7 순찰/함대 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-07 | PatrolRoute | `/patrol-route` | 상황실 | AI 단일 함정 순찰 경로 추천 (웨이포인트, 거리/시간/연료 산출) | 함정 선택, 구역 조건 | 추천 경로, 웨이포인트 목록 | EnforcementPlan, RiskMap | 함정 출동 (ShipAgent) |
|
|
||||||
| SFR-08 | FleetOptimization | `/fleet-optimization` | 상황실 | 다함정 협력형 경로 최적화 (커버리지 시뮬레이션, 승인 워크플로) | 함대 목록, 구역 조건 | 최적화 결과, 커버리지 비교 | EnforcementPlan, PatrolRoute | 함정 출동 (ShipAgent) |
|
|
||||||
|
|
||||||
### 1.8 감시/지도 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | LiveMapView | `/events` | 상황실 | 실시간 해역 감시 지도 (AIS 선박 + 이벤트 경보 + 아군 함정) | 실시간 AIS/이벤트 스트림 | 지도 마커, 이벤트 카드, 위험도 바 | 탐지 엔진 전체 | EventList, AIAlert |
|
|
||||||
| - | MapControl | `/map-control` | 상황실/관리자 | 해역 통제 관리 (해상사격 훈련구역도 No.462, 군/해경 구역) | 구역 데이터 | 훈련구역 지도, 상태 테이블 | 국립해양조사원 데이터 | LiveMapView (레이어) |
|
|
||||||
|
|
||||||
### 1.9 대시보드/모니터링 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | Dashboard | `/dashboard` | 전체 | 종합 상황판 (KPI, 타임라인, 위험선박 TOP8, 함정 현황, 해역 위험도, 시간대별 탐지 추이) | 전 시스템 데이터 집계 | 한눈에 보는 현황 | 탐지/순찰/이벤트 전체 | 각 상세 페이지로 드릴다운 |
|
|
||||||
| SFR-12 | MonitoringDashboard | `/monitoring` | 상황실 | 모니터링 및 경보 현황판 (KPI, 24시간 추이, 탐지 유형 분포, 실시간 이벤트) | 경보/탐지 데이터 | 경보 현황 대시보드 | 탐지 엔진, EventList | AIAlert, EnforcementPlan |
|
|
||||||
|
|
||||||
### 1.10 이벤트/이력 (2개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | EventList | `/event-list` | 상황실/분석관 | 이벤트 전체 목록 (검색/정렬/페이징/엑셀/출력), 15건+ 이벤트 | 필터 조건 | 이벤트 테이블, 엑셀 내보내기 | 탐지 엔진, LiveMapView | EnforcementHistory, ReportManagement |
|
|
||||||
| SFR-11 | EnforcementHistory | `/enforcement-history` | 분석관 | 단속/탐지 이력 관리 (AI 매칭 검증 포함) | 검색 조건 | 이력 테이블, AI 일치 여부 | EventList, 현장 단속 | ReportManagement, Statistics |
|
|
||||||
|
|
||||||
### 1.11 현장 대응 (3개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-15 | MobileService | `/mobile-service` | 현장 단속요원 | 모바일 앱 프리뷰 (위험도/의심선박/경로추천/경보, 푸시 설정) | 모바일 위치, 푸시 설정 | 경보 수신, 지도 조회 | AIAlert, LiveMapView | 현장 단속 수행 |
|
|
||||||
| SFR-16 | ShipAgent | `/ship-agent` | 현장 단속요원 | 함정용 Agent 관리 (배포/동기화 상태, 버전 관리) | 함정 Agent 설치 | Agent 상태 조회, 동기화 | PatrolRoute, FleetOptimization | 현장 단속 수행 |
|
|
||||||
| SFR-17 | AIAlert | `/ai-alert` | 상황실/현장 | AI 탐지 알림 자동 발송 (함정/관제요원 대상, 탐지시각/좌표/유형/신뢰도 포함) | 탐지 이벤트 트리거 | 알림 발송, 수신 확인 | MonitoringDashboard, EventList | MobileService, ShipAgent |
|
|
||||||
|
|
||||||
### 1.12 통계/외부연계/보고 (3개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| SFR-13 | Statistics | `/statistics` | 상황실/분석관 | 통계/지표/성과 분석 (월별 추이, 위반유형, KPI 달성률) | 기간/유형 필터 | 차트, KPI 테이블, 보고서 | EnforcementHistory, EventList | 외부 보고, 전략 수립 |
|
|
||||||
| SFR-14 | ExternalService | `/external-service` | 관리자/외부 | 외부 서비스 제공 (해수부/수협/기상청 API/파일 연계, 비식별/익명화 정책) | 서비스 설정 | API 호출 수, 연계 상태 | Statistics, 탐지 결과 | 외부기관 |
|
|
||||||
| - | ReportManagement | `/reports` | 분석관/상황실 | 증거 관리 및 보고서 생성 (사건별 자동 패키징) | 사건 선택, 증거 파일 업로드 | 보고서 PDF, 증거 패키지 | EnforcementHistory, EventList | 검찰/외부기관 |
|
|
||||||
|
|
||||||
### 1.13 선박 상세 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | VesselDetail | `/vessel/:id` | 분석관/상황실 | 선박 상세 정보 (AIS 데이터, 항적, 입항 이력, 선원 정보, 비허가 선박 목록) | 선박 ID/MMSI | 상세 프로필, 지도 항적 | LiveMapView, DarkVessel, EventList | EnforcementPlan, ReportManagement |
|
|
||||||
|
|
||||||
### 1.14 시스템 관리 (1개)
|
|
||||||
|
|
||||||
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| - | AdminPanel | `/admin` | 관리자 | 시스템 인프라 관리 (서버 상태, CPU/메모리/디스크 모니터링) | - | 서버 상태 대시보드 | - | 시스템 안정성 보장 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 업무 파이프라인 (4개)
|
|
||||||
|
|
||||||
### 2.1 탐지 파이프라인
|
|
||||||
|
|
||||||
불법 조업을 탐지하고 실시간 감시하여 현장 작전까지 연결하는 핵심 파이프라인.
|
|
||||||
|
|
||||||
```
|
|
||||||
AIS/레이더/위성 신호
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────┐
|
|
||||||
│ DataHub │ ← 통합데이터 허브 (VTS, AIS, V-PASS, E-Nav 수집)
|
|
||||||
└────┬────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────┐
|
|
||||||
│ AI 탐지 엔진 (AIModelManagement 관리) │
|
|
||||||
│ │
|
|
||||||
│ DarkVesselDetection ─ AIS 조작/위장/소실 │
|
|
||||||
│ GearDetection ─────── 불법 어구 탐지 │
|
|
||||||
│ ChinaFishing ──────── 중국어선 통합 감시 │
|
|
||||||
│ TransferDetection ─── 환적 행위 탐지 │
|
|
||||||
│ GearIdentification ── 어구 국적 판별 │
|
|
||||||
└──────────────┬───────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────┐ ┌───────────────────┐
|
|
||||||
│ RiskMap │─────▶│ LiveMapView │ ← 실시간 지도 감시
|
|
||||||
└────┬─────┘ │ MonitoringDashboard│ ← 경보 현황판
|
|
||||||
│ └───────────────────┘
|
|
||||||
▼
|
|
||||||
┌──────────────────┐
|
|
||||||
│ EnforcementPlan │ ← 단속 우선지역 예보
|
|
||||||
└────────┬─────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐ ┌───────────────────┐
|
|
||||||
│ PatrolRoute │─────▶│ FleetOptimization │ ← 다함정 최적화
|
|
||||||
└──────┬───────┘ └─────────┬─────────┘
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌──────────┐
|
|
||||||
│ AIAlert │ ← 함정/관제 자동 알림 발송
|
|
||||||
└────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
현장 작전 (MobileService, ShipAgent)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 대응 파이프라인
|
|
||||||
|
|
||||||
AI 알림 수신 후 현장 단속, 이력 기록, 보고서 생성까지의 대응 프로세스.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────┐
|
|
||||||
│ AIAlert │ ← AI 탐지 알림 자동 발송
|
|
||||||
└────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ 현장 대응 │
|
|
||||||
│ │
|
|
||||||
│ MobileService ── 모바일 경보 수신│
|
|
||||||
│ ShipAgent ────── 함정 Agent 연동 │
|
|
||||||
└──────────────┬───────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
현장 단속 수행
|
|
||||||
(정선/검문/나포/퇴거)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ EnforcementHistory │ ← 단속 이력 등록, AI 매칭 검증
|
|
||||||
└──────────┬───────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ ReportManagement │ ← 증거 패키징, 보고서 생성
|
|
||||||
└──────────┬───────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
검찰/외부기관 (ExternalService 통해 연계)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 분석 파이프라인
|
|
||||||
|
|
||||||
축적된 데이터를 분석하여 전략적 의사결정을 지원하는 파이프라인.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐
|
|
||||||
│ Statistics │ ← 월별 추이, 위반유형, KPI 달성률
|
|
||||||
└──────┬──────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────┐
|
|
||||||
│ RiskMap │ ← 격자 위험도 + MTIS 해양사고 통계
|
|
||||||
└────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
│ VesselDetail │ ← 개별 선박 심층 분석 (항적, 이력)
|
|
||||||
└──────┬───────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
│ AIAssistant │ ← 자연어 Q&A (법령 조회, 대응 절차)
|
|
||||||
└──────┬───────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
전략 수립 (순찰 패턴, 탐지 규칙 조정)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 관리 파이프라인
|
|
||||||
|
|
||||||
시스템 접근 제어, 환경 설정, 데이터 관리, 인프라 모니터링 파이프라인.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────┐
|
|
||||||
│ AccessControl │ ← RBAC 역할/권한 설정
|
|
||||||
└───────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────┐
|
|
||||||
│ LoginPage │ ← SSO/GPKI/비밀번호 인증
|
|
||||||
└──────┬─────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────┐
|
|
||||||
│ 시스템 설정/관리 │
|
|
||||||
│ │
|
|
||||||
│ SystemConfig ──── 공통코드/환경설정 │
|
|
||||||
│ NoticeManagement ── 공지/배너/팝업 │
|
|
||||||
│ DataHub ────────── 데이터 수집 관리 │
|
|
||||||
│ AdminPanel ────── 서버/인프라 모니터 │
|
|
||||||
└──────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 사용자 역할별 페이지 접근 매트릭스
|
|
||||||
|
|
||||||
시스템에 정의된 5개 역할(LoginPage의 `DEMO_ACCOUNTS` 및 AccessControl의 `ROLES` 기반)에 대한 페이지 접근 권한.
|
|
||||||
|
|
||||||
### 3.1 역할 정의
|
|
||||||
|
|
||||||
| 역할 | 코드 | 설명 | 인원(시뮬) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 시스템 관리자 | `ADMIN` | 전체 시스템 관리 권한 | 3명 |
|
|
||||||
| 상황실 운영자 | `OPERATOR` | 상황판, 통계, 경보 운영 | 12명 |
|
|
||||||
| 분석 담당자 | `ANALYST` | AI 모델, 통계, 항적 분석 | 8명 |
|
|
||||||
| 현장 단속요원 | `FIELD` | 함정 Agent, 모바일 대응 | 45명 |
|
|
||||||
| 유관기관 열람자 | `VIEWER` | 공유 대시보드 열람 | 6명 |
|
|
||||||
|
|
||||||
### 3.2 접근 매트릭스
|
|
||||||
|
|
||||||
| 페이지 | ADMIN | OPERATOR | ANALYST | FIELD | VIEWER |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| **인증/관리** | | | | | |
|
|
||||||
| LoginPage | O | O | O | O | O |
|
|
||||||
| AccessControl | O | - | - | - | - |
|
|
||||||
| SystemConfig | O | - | - | - | - |
|
|
||||||
| NoticeManagement | O | - | - | - | - |
|
|
||||||
| AdminPanel | O | - | - | - | - |
|
|
||||||
| **데이터/AI** | | | | | |
|
|
||||||
| DataHub | O | - | - | - | - |
|
|
||||||
| AIModelManagement | O | - | O | - | - |
|
|
||||||
| MLOpsPage | O | - | O | - | - |
|
|
||||||
| AIAssistant | O | O | O | - | - |
|
|
||||||
| **탐지** | | | | | |
|
|
||||||
| DarkVesselDetection | O | - | O | - | - |
|
|
||||||
| GearDetection | O | - | O | - | - |
|
|
||||||
| ChinaFishing | O | O | O | - | - |
|
|
||||||
| TransferDetection | O | - | O | - | - |
|
|
||||||
| **위험도/계획** | | | | | |
|
|
||||||
| RiskMap | O | O | O | - | - |
|
|
||||||
| EnforcementPlan | O | O | - | - | - |
|
|
||||||
| **순찰** | | | | | |
|
|
||||||
| PatrolRoute | O | O | - | - | - |
|
|
||||||
| FleetOptimization | O | O | - | - | - |
|
|
||||||
| **감시/지도** | | | | | |
|
|
||||||
| LiveMapView | O | O | O | - | - |
|
|
||||||
| MapControl | O | O | - | - | - |
|
|
||||||
| **대시보드** | | | | | |
|
|
||||||
| Dashboard | O | O | O | O | O |
|
|
||||||
| MonitoringDashboard | O | O | - | - | - |
|
|
||||||
| **이벤트/이력** | | | | | |
|
|
||||||
| EventList | O | O | O | O | - |
|
|
||||||
| EnforcementHistory | O | - | O | - | - |
|
|
||||||
| **현장 대응** | | | | | |
|
|
||||||
| MobileService | O | - | - | O | - |
|
|
||||||
| ShipAgent | O | - | - | O | - |
|
|
||||||
| AIAlert | O | O | - | O | - |
|
|
||||||
| **통계/보고** | | | | | |
|
|
||||||
| Statistics | O | O | O | - | - |
|
|
||||||
| ExternalService | O | - | - | - | O |
|
|
||||||
| ReportManagement | O | O | O | - | - |
|
|
||||||
| **선박 상세** | | | | | |
|
|
||||||
| VesselDetail | O | O | O | - | - |
|
|
||||||
|
|
||||||
### 3.3 역할별 요약
|
|
||||||
|
|
||||||
| 역할 | 접근 가능 페이지 | 페이지 수 |
|
|
||||||
|---|---|---|
|
|
||||||
| **시스템 관리자** (ADMIN) | 전체 페이지 | 31 |
|
|
||||||
| **상황실 운영자** (OPERATOR) | Dashboard, MonitoringDashboard, LiveMapView, MapControl, EventList, EnforcementPlan, PatrolRoute, FleetOptimization, ChinaFishing, RiskMap, Statistics, ReportManagement, AIAssistant, AIAlert, VesselDetail | 15 |
|
|
||||||
| **분석 담당자** (ANALYST) | Dashboard, DarkVesselDetection, GearDetection, ChinaFishing, TransferDetection, RiskMap, LiveMapView, EventList, EnforcementHistory, Statistics, ReportManagement, VesselDetail, AIAssistant, AIModelManagement, MLOpsPage | 15 |
|
|
||||||
| **현장 단속요원** (FIELD) | Dashboard, MobileService, ShipAgent, AIAlert, EventList | 5 |
|
|
||||||
| **유관기관 열람자** (VIEWER) | Dashboard, ExternalService | 2 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 페이지 간 데이터 흐름 요약
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐
|
|
||||||
│ LoginPage │
|
|
||||||
│ (인증 게이트) │
|
|
||||||
└────────┬─────────┘
|
|
||||||
│
|
|
||||||
┌────────────────────┬┴──────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
|
|
||||||
│ 관리 파이프라인│ │ 탐지 파이프라인 │ │ 현장 대응 │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ AccessControl│ │ DataHub │ │ MobileSvc │
|
|
||||||
│ SystemConfig │ │ ↓ │ │ ShipAgent │
|
|
||||||
│ NoticeManage │ │ AI탐지엔진 │ │ AIAlert │
|
|
||||||
│ DataHub │ │ (DV/Gear/CN/TR)│ └──────┬──────┘
|
|
||||||
│ AdminPanel │ │ ↓ │ │
|
|
||||||
└──────────────┘ │ RiskMap │ │
|
|
||||||
│ ↓ │ ▼
|
|
||||||
│ EnforcementPlan │ ┌──────────────┐
|
|
||||||
│ ↓ │ │ 대응 파이프라인│
|
|
||||||
│ PatrolRoute │ │ │
|
|
||||||
│ FleetOptim │ │ Enforcement │
|
|
||||||
│ ↓ │ │ History │
|
|
||||||
│ LiveMapView │ │ ReportManage │
|
|
||||||
│ Monitoring │ │ ExternalSvc │
|
|
||||||
└────────┬────────┘ └──────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ 분석 파이프라인 │
|
|
||||||
│ │
|
|
||||||
│ Statistics │
|
|
||||||
│ VesselDetail │
|
|
||||||
│ AIAssistant │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 미할당 SFR 참고
|
|
||||||
|
|
||||||
현재 라우트에서 확인되는 SFR 번호 기준, 아래 기능은 기존 페이지에 통합되어 있다:
|
|
||||||
|
|
||||||
- **Dashboard**: SFR 번호 미부여, 종합 상황판 (기존 유지)
|
|
||||||
- **LiveMapView**: SFR 번호 미부여, 실시간 감시 지도
|
|
||||||
- **EventList**: SFR-02 공통 컴포넌트 적용 대상으로 분류
|
|
||||||
- **MapControl**: SFR 번호 미부여, 해역 통제 관리
|
|
||||||
- **VesselDetail**: SFR 번호 미부여, 선박 상세
|
|
||||||
- **ReportManagement**: SFR 번호 미부여, 증거/보고서 관리
|
|
||||||
- **AdminPanel**: SFR 번호 미부여, 인프라 관리
|
|
||||||
- **GearIdentification**: ChinaFishing 내 서브 컴포넌트
|
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,7 +1,8 @@
|
|||||||
# SFR 요구사항별 화면 사용 가이드
|
# SFR 요구사항별 화면 사용 가이드
|
||||||
|
|
||||||
> **문서 작성일:** 2026-04-06
|
> **문서 작성일:** 2026-04-06
|
||||||
> **시스템 버전:** v0.1.0 (프로토타입)
|
> **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
|
||||||
|
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
|
||||||
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
|
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
|
||||||
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
|
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
|
||||||
|
|
||||||
@ -11,7 +12,12 @@
|
|||||||
|
|
||||||
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
|
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
|
||||||
|
|
||||||
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다.
|
### 시스템 현황 (2026-04-17 기준)
|
||||||
|
- **프런트엔드·백엔드·분석엔진(prediction) 운영 배포 완료** — 자체 JWT 인증 + 트리 기반 RBAC + 감사 로그 + 65+ API
|
||||||
|
- **AI 분석 엔진(prediction)**: 5분 주기로 AIS 원천 데이터(snpdb)를 분석하여 결과를 `kcgaidb` 에 자동 저장 (14 알고리즘 + DAR-03 G-01~G-06)
|
||||||
|
- **실시간 연동 화면**: Dashboard / MonitoringDashboard / ChinaFishing / DarkVesselDetection / GearDetection / EnforcementHistory / EventList / AIAlert / Statistics / AccessControl / PermissionsPanel / Audit 등 **15+ 화면이 실 API + prediction 결과를 실시간으로 표시**
|
||||||
|
- **Mock 화면**: DataHub / AIModelManagement / RiskMap / PatrolRoute / FleetOptimization / ExternalService / ShipAgent / MLOpsPage / AIAssistant 는 UI 완성, 백엔드/AI 엔진 연동은 단계적 추가 중
|
||||||
|
- **자세한 추적 매트릭스**: `docs/sfr-traceability.md` v3.0 참조
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -55,17 +61,18 @@
|
|||||||
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
|
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
|
||||||
- 로그인 후 역할에 따른 메뉴 접근 제어
|
- 로그인 후 역할에 따른 메뉴 접근 제어
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
|
- ✅ 로그인 화면 UI + 자체 ID/PW 인증 + JWT 쿠키 세션 + 역할별 데모 계정 5종 실 로그인
|
||||||
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
|
- ✅ 비밀번호 정책(9자 이상 영문+숫자+특수) + 5회 실패 30분 잠금 + BCrypt 해시
|
||||||
|
- ✅ 트리 기반 RBAC (47 리소스 노드, Level 0 13개 + Level 1 32개, 5 operation) + Caffeine 10분 TTL
|
||||||
|
- ✅ 모든 로그인 시도 감사 로그 저장 및 조회 (로그인 이력 화면)
|
||||||
|
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 (사이드바/라우트 가드)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정 (기업 환경 연동):**
|
||||||
- 🔲 SSO(Single Sign-On) 연동
|
- 🔲 SSO(해양경찰 통합인증) 연동
|
||||||
- 🔲 GPKI(정부 공인인증서) 인증 연동
|
- 🔲 GPKI(정부 공인인증서) 인증 연동
|
||||||
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
|
- 🔲 공무원증 기반 인증 연동
|
||||||
|
- 🔲 인사 시스템 연동 역할 자동 부여
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -83,16 +90,17 @@
|
|||||||
- 역할별 접근 가능 메뉴 및 기능 권한 설정
|
- 역할별 접근 가능 메뉴 및 기능 권한 설정
|
||||||
- 사용자 목록 조회 및 역할 할당
|
- 사용자 목록 조회 및 역할 할당
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
|
- ✅ 트리 기반 RBAC 실 운영 — 47 리소스 노드 × 5 operation (READ/CREATE/UPDATE/DELETE/EXPORT) × 다중 역할 OR 합집합
|
||||||
- ✅ 권한 설정 화면 레이아웃 및 인터랙션
|
- ✅ 역할별 권한 매트릭스 시각화 (셀 클릭 Y → N → 상속 사이클)
|
||||||
|
- ✅ 부모 READ 거부 시 자식 강제 거부, 상속 표시
|
||||||
|
- ✅ 역할 CRUD (admin:role-management) + 권한 매트릭스 갱신 (admin:permission-management)
|
||||||
|
- ✅ 사용자-역할 할당 다이얼로그 (admin:user-management)
|
||||||
|
- ✅ 모든 권한 변경은 `auth_audit_log` 에 자동 기록 (ROLE_CREATE/UPDATE/DELETE/PERM_UPDATE/USER_ROLE_ASSIGN)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD
|
- 🔲 권한 변경 이력 UI (auth_audit_log 조회는 현재 별도 화면)
|
||||||
- 🔲 감사 로그(권한 변경 이력) 기록
|
- 🔲 역할 템플릿 복제 기능
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -369,17 +377,18 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 의심 선박 상세 프로필 및 이동 궤적 조회
|
- 의심 선박 상세 프로필 및 이동 궤적 조회
|
||||||
- 위험도 등급별 분류 표시
|
- 위험도 등급별 분류 표시
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 의심 선박 7척 목록/지도 시각화
|
- ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
|
||||||
- ✅ 5가지 행동 패턴 분석 결과 UI
|
- ✅ Dark Vessel 11패턴 기반 0~100점 연속 점수 + 4단계 tier(CRITICAL≥70 / HIGH≥50 / WATCH≥30 / NONE)
|
||||||
|
- ✅ DarkDetailPanel — 선박 선택 시 ScoreBreakdown으로 P1~P11 각 패턴별 기여도 표시
|
||||||
|
- ✅ 지도 기반 실시간 위치 + tier별 색상 구분 (라이트/다크 모드 대응)
|
||||||
|
- ✅ 최근 1시간 / 중국 선박(MMSI 412*) 필터, MMSI/선박명/패턴 검색
|
||||||
|
- ✅ 특이운항 미니맵 (24h 궤적 + DARK/SPOOFING/TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 구간 병합 하이라이트)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 AI Dark Vessel 탐지 엔진 연동
|
- 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
|
||||||
- 🔲 실시간 AIS 데이터 분석 연동
|
|
||||||
- 🔲 SAR(위성영상) 기반 탐지 연동
|
- 🔲 SAR(위성영상) 기반 탐지 연동
|
||||||
|
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -398,16 +407,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 해역별 중국 어선 밀집도 분석
|
- 해역별 중국 어선 밀집도 분석
|
||||||
- 시계열 활동 패턴 분석
|
- 시계열 활동 패턴 분석
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 중국 어선 분석 종합 대시보드 UI
|
- ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동** — `/api/analysis/*` 경유, MMSI prefix `412` 고정
|
||||||
- ✅ 지도 기반 활동 현황 시각화
|
- ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
|
||||||
|
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
|
||||||
|
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
|
||||||
|
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
|
- 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
|
||||||
- 🔲 실시간 데이터 기반 분석 갱신
|
- 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
|
||||||
|
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -426,17 +436,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
|
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
|
||||||
- 탐지 이미지 확인
|
- 탐지 이미지 확인
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 어구 6건 탐지 결과 목록/지도 UI
|
- ✅ **DAR-03 G-01~G-06 실시간 탐지 결과** — prediction 5분 주기 + 한중어업협정 906척 레지스트리(V029) 매칭 53%+
|
||||||
- ✅ 어구 식별 결정트리 시각화
|
- ✅ G코드별 탐지: G-01(수역-어구 불일치) / G-02(금어기) / G-03(미등록 어구) / G-04(MMSI cycling) / G-05(고정어구 drift) / G-06(쌍끌이 — STRONG/PROBABLE/SUSPECT tier)
|
||||||
|
- ✅ 어구 그룹 지도 (ZONE_I~IV 폴리곤 + GeoJsonLayer + IconLayer) + 세부 필터 패널(해역/판정/위험도/모선 상태/허가/멤버 수) + localStorage 영속화
|
||||||
|
- ✅ GearDetailPanel — 후보 클릭 → 점수 근거(관측 7종 + 보정 3종) + 모선 확정/제외 버튼
|
||||||
|
- ✅ 24h 궤적 리플레이 (GearReplayController + TripsLayer, SPEED_FACTOR=2880, 24h→30s)
|
||||||
|
- ✅ 어구/어망 판별 화면 — 허가코드/어구물리특성/발견위치 입력 → 국적 판별(한/중/미확인) + 판별 근거·경고·AI 탐지 Rule·교차 검증 파이프라인
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반)
|
- 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
|
||||||
- 🔲 실시간 CCTV/SAR 영상 분석 연동
|
- 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
|
||||||
- 🔲 탐지 결과 자동 분류 및 알림
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -455,17 +465,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 이력 상세 정보 조회 및 검색/필터
|
- 이력 상세 정보 조회 및 검색/필터
|
||||||
- 이력 데이터 엑셀 내보내기
|
- 이력 데이터 엑셀 내보내기
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 단속 이력 6건 목록/상세 UI
|
- ✅ **실시간 이벤트 조회** — `/api/events` 페이징/필터/확인(ACK)/상태 변경
|
||||||
- ✅ AI 매칭 검증 결과 표시
|
- ✅ **단속 이력 CRUD** — `/api/enforcement/records` (GET/POST/PATCH) + ENF-yyyyMMdd-NNNN UID 자동 발급
|
||||||
|
- ✅ 이벤트 발생 → 확인 → 단속 등록 → 오탐 처리 워크플로우 (액션 버튼 4종)
|
||||||
|
- ✅ 모든 쓰기 액션 `auth_audit_log` 자동 기록 (ENFORCEMENT_CREATE / ENFORCEMENT_UPDATE / ACK_EVENT / UPDATE_EVENT_STATUS)
|
||||||
|
- ✅ KPI 카운트 (CRITICAL/HIGH/MEDIUM/LOW) + 엑셀 내보내기 + 출력
|
||||||
|
- ✅ 단속 완료 시 prediction_events.status 자동 RESOLVED 갱신
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 단속 이력 DB 연동 (조회/등록/수정)
|
- 🔲 증거 파일(사진/영상) 업로드 서버 연동
|
||||||
- 🔲 AI 매칭 검증 엔진 연동
|
- 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
|
||||||
- 🔲 탐지-단속 연계 자동 분석
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -487,17 +497,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 함정 배치 현황 요약
|
- 함정 배치 현황 요약
|
||||||
- 실시간 경보 알림 표시
|
- 실시간 경보 알림 표시
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
|
- ✅ **실시간 KPI 카드** — `/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
|
||||||
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
|
- ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
|
||||||
|
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
|
||||||
|
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실시간 데이터 연동 (WebSocket 등)
|
- 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
|
||||||
- 🔲 KPI 수치 실시간 갱신
|
- 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
|
||||||
- 🔲 히트맵/타임라인 실시간 업데이트
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -516,17 +524,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 경보 처리(확인/대응/종결) 워크플로우
|
- 경보 처리(확인/대응/종결) 워크플로우
|
||||||
- 경보 발생 이력 조회
|
- 경보 발생 이력 조회
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 경보 등급별 현황판 UI
|
- ✅ **실시간 경보 수신** — `/api/events` + `/api/alerts` 실 API 연동, prediction event_generator 4룰 기반
|
||||||
- ✅ 경보 목록/상세 조회 화면
|
- ✅ 경보 등급별(CRITICAL/HIGH/MEDIUM/LOW) 현황 + KPI 카운트
|
||||||
|
- ✅ 경보 처리 워크플로우 — 확인(ACK) → 단속 등록 → 오탐 처리 (DB 저장 + `auth_audit_log` 기록)
|
||||||
|
- ✅ 시스템 상태 패널 (백엔드/AI 분석 엔진/DB 상태 실시간 표시, 30초 자동 갱신)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실시간 경보 수신 연동
|
- 🔲 경보 자동 에스컬레이션 정책
|
||||||
- 🔲 경보 처리 워크플로우 DB 연동
|
- 🔲 경보 룰 커스터마이즈 UI
|
||||||
- 🔲 경보 자동 에스컬레이션
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -545,17 +551,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 선박/이벤트 클릭 시 상세 정보 팝업
|
- 선박/이벤트 클릭 시 상세 정보 팝업
|
||||||
- 지도 확대/축소 및 해역 필터링
|
- 지도 확대/축소 및 해역 필터링
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ LiveMap 기반 실시간 감시 지도 UI
|
- ✅ **실시간 선박 위치 + 이벤트 마커** — prediction 5분 주기 분석 결과(`vessel_analysis_results.lat/lon`) + `prediction_events` 기반
|
||||||
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
|
- ✅ MapLibre GL 5 + deck.gl 9 GPU 렌더링 (40만척+ 지원)
|
||||||
|
- ✅ 위험도별 마커 opacity/radius 차등 (2026-04-17 `ALERT_LEVEL_MARKER_OPACITY/RADIUS` 헬퍼 적용)
|
||||||
|
- ✅ 이벤트 상세 패널 + 고위험 사건 실시간 알림 (LIVE 표시)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실시간 AIS/VMS 데이터 연동
|
- 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
|
||||||
- 🔲 WebSocket 기반 실시간 위치 업데이트
|
- 🔲 SAR 위성영상 오버레이
|
||||||
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -601,17 +605,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 기간별/해역별/유형별 필터링
|
- 기간별/해역별/유형별 필터링
|
||||||
- 통계 데이터 엑셀 내보내기 및 인쇄
|
- 통계 데이터 엑셀 내보내기 및 인쇄
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
|
- ✅ **실시간 통계 데이터** — `/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
|
||||||
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
|
- ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
|
||||||
|
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
|
||||||
|
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 통계 데이터 DB 연동
|
- 🔲 보고서 자동 생성 (PDF/HWP) — 현재는 UI만
|
||||||
- 🔲 실제 운영 데이터 기반 KPI 자동 산출
|
- 🔲 맞춤형 지표 대시보드 설정
|
||||||
- 🔲 맞춤형 보고서 생성 기능
|
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -743,17 +745,15 @@ AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송
|
|||||||
- 알림 수신자 설정 및 발송
|
- 알림 수신자 설정 및 발송
|
||||||
- 알림 전송 결과(성공/실패) 확인
|
- 알림 전송 결과(성공/실패) 확인
|
||||||
|
|
||||||
**구현 완료:**
|
**구현 완료 (2026-04-17 기준):**
|
||||||
- ✅ 알림 5건 전송 현황 UI
|
- ✅ **AI 알림 이력 실 API 조회** — `/api/alerts` 연동 (2026-04-17 AlertService 계층 분리)
|
||||||
- ✅ 알림 유형별 분류 및 상세 조회
|
- ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
|
||||||
|
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
|
- 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
|
||||||
- 🔲 AI 분석 결과 기반 자동 알림 트리거
|
- 🔲 알림 템플릿 엔진
|
||||||
- 🔲 알림 발송 이력 DB 연동
|
- 🔲 수신자 그룹 관리
|
||||||
|
|
||||||
**보완 필요:**
|
|
||||||
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -857,15 +857,27 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 부록: 현재 시스템 상태 요약
|
## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
|
||||||
|
|
||||||
| 항목 | 상태 |
|
| 항목 | 상태 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| UI 구현 | 모든 SFR 완료 |
|
| UI 구현 | 모든 SFR 완료 |
|
||||||
| 백엔드 연동 | 미구현 (전체) |
|
| **백엔드 연동** | **15+ 화면 실 API 연동 완료** (Auth/RBAC/Audit/Events/Alerts/Enforcement/Stats/Analysis/Master 등 65+ API) |
|
||||||
| 데이터 | 시연용 샘플 데이터 |
|
| **AI 분석 엔진 (prediction)** | **운영 중** — 5분 주기로 snpdb 분석 → kcgaidb 저장, 14 알고리즘 + DAR-03 G-01~G-06 |
|
||||||
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
|
| **데이터** | 실 AIS 원천(snpdb) + prediction 분석 결과 + 자체 DB 저장 데이터 (일부 화면은 여전히 Mock) |
|
||||||
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
|
| **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
|
||||||
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
|
| **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
|
||||||
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
|
| **AI 모델** | Dark Vessel 11패턴 / DAR-03 G-01~G-06 / 환적 5단계 / 경량 risk 등 14종 운영 중 (일부 모델은 Mock 계획 단계) |
|
||||||
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) |
|
| **외부 시스템** | snpdb / gc-signal-batch 연동 완료. 유관기관 OpenAPI(GICOMS/MTIS 등)는 미연동 |
|
||||||
|
| **디자인 시스템** | `design-system.html` 쇼케이스 SSOT 전영역 준수, 라이트/다크 모드 완전 대응 |
|
||||||
|
| **다국어** | 한/영 alert/confirm/aria-label 전수 치환 완료 (JSX placeholder 35건은 후속 과제) |
|
||||||
|
| **모바일 앱** | 웹 시뮬레이션만 제공 (PWA/네이티브 앱 미개발) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
| 일자 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-04-06 | 초기 작성 (프론트엔드 프로토타입 v0.1.0 기준) |
|
||||||
|
| 2026-04-17 | 헤더 + SFR-01/02/09/10/11/12/13/17 주요 섹션 업데이트. 실 API 연동 / prediction 운영 상태 / 2026-04-17 PR #A/#B/#C 반영 |
|
||||||
|
|||||||
@ -39,6 +39,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
|||||||
'features/detection/ChinaFishing': lazy(() =>
|
'features/detection/ChinaFishing': lazy(() =>
|
||||||
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
|
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
|
||||||
),
|
),
|
||||||
|
'features/detection/GearCollisionDetection': lazy(() =>
|
||||||
|
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
|
||||||
|
),
|
||||||
// ── 단속·이벤트 ──
|
// ── 단속·이벤트 ──
|
||||||
'features/enforcement/EnforcementHistory': lazy(() =>
|
'features/enforcement/EnforcementHistory': lazy(() =>
|
||||||
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
||||||
|
|||||||
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AlertOctagon, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import { Textarea } from '@shared/components/ui/textarea';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||||
|
import {
|
||||||
|
GEAR_COLLISION_STATUS_ORDER,
|
||||||
|
getGearCollisionStatusIntent,
|
||||||
|
getGearCollisionStatusLabel,
|
||||||
|
} from '@shared/constants/gearCollisionStatuses';
|
||||||
|
import {
|
||||||
|
getGearCollisionStats,
|
||||||
|
listGearCollisions,
|
||||||
|
resolveGearCollision,
|
||||||
|
type GearCollision,
|
||||||
|
type GearCollisionResolveAction,
|
||||||
|
type GearCollisionStats,
|
||||||
|
} from '@/services/gearCollisionApi';
|
||||||
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 페이지.
|
||||||
|
*
|
||||||
|
* 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 에 공존 송출되는 경우를 목록화하고
|
||||||
|
* 운영자가 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE 로 분류할 수 있게 한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
||||||
|
const DEFAULT_HOURS = 48;
|
||||||
|
|
||||||
|
export function GearCollisionDetection() {
|
||||||
|
const { t } = useTranslation('detection');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
|
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<GearCollision[]>([]);
|
||||||
|
const [stats, setStats] = useState<GearCollisionStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<string>('');
|
||||||
|
const [nameFilter, setNameFilter] = useState<string>('');
|
||||||
|
const [selected, setSelected] = useState<GearCollision | null>(null);
|
||||||
|
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('REVIEWED');
|
||||||
|
const [resolveNote, setResolveNote] = useState('');
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const [page, summary] = await Promise.all([
|
||||||
|
listGearCollisions({
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
severity: severityFilter || undefined,
|
||||||
|
name: nameFilter || undefined,
|
||||||
|
hours: DEFAULT_HOURS,
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
|
getGearCollisionStats(DEFAULT_HOURS),
|
||||||
|
]);
|
||||||
|
setRows(page.content);
|
||||||
|
setStats(summary);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [statusFilter, severityFilter, nameFilter, t]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// 선택된 row 와 현재 목록의 동기화
|
||||||
|
const syncedSelected = useMemo(
|
||||||
|
() => selected ? rows.find((r) => r.id === selected.id) ?? selected : null,
|
||||||
|
[rows, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols: DataColumn<GearCollision & Record<string, unknown>>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
|
||||||
|
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
|
||||||
|
render: (_, row) => (
|
||||||
|
<span className="font-mono text-[10px] text-label">
|
||||||
|
{row.mmsiLo} ↔ {row.mmsiHi}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
|
||||||
|
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
|
||||||
|
width: '90px', align: 'center', sortable: true,
|
||||||
|
render: (v) => <span className="font-mono text-label">{v as number}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'),
|
||||||
|
width: '110px', align: 'right', sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
const n = typeof v === 'number' ? v : Number(v ?? 0);
|
||||||
|
return <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'severity', label: t('gearCollision.columns.severity'),
|
||||||
|
width: '90px', align: 'center', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||||
|
{getAlertLevelLabel(v as string, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status', label: t('gearCollision.columns.status'),
|
||||||
|
width: '110px', align: 'center', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
|
||||||
|
{getGearCollisionStatusLabel(v as string, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
|
||||||
|
width: '130px', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [t, tc, lang]);
|
||||||
|
|
||||||
|
const handleResolve = useCallback(async () => {
|
||||||
|
if (!syncedSelected) return;
|
||||||
|
const ok = window.confirm(t('gearCollision.resolve.confirmPrompt'));
|
||||||
|
if (!ok) return;
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const updated = await resolveGearCollision(syncedSelected.id, {
|
||||||
|
action: resolveAction,
|
||||||
|
note: resolveNote || undefined,
|
||||||
|
});
|
||||||
|
setSelected(updated);
|
||||||
|
setResolveNote('');
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed'));
|
||||||
|
} finally {
|
||||||
|
setResolving(false);
|
||||||
|
}
|
||||||
|
}, [syncedSelected, resolveAction, resolveNote, loadData, t]);
|
||||||
|
|
||||||
|
const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={AlertOctagon}
|
||||||
|
iconColor="text-orange-600 dark:text-orange-400"
|
||||||
|
title={t('gearCollision.title')}
|
||||||
|
description={t('gearCollision.desc')}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
|
{t('gearCollision.list.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title={t('gearCollision.stats.title')}>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.open')}
|
||||||
|
value={statusCount('OPEN')}
|
||||||
|
intent="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.reviewed')}
|
||||||
|
value={statusCount('REVIEWED')}
|
||||||
|
intent="info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.confirmed')}
|
||||||
|
value={statusCount('CONFIRMED_ILLEGAL')}
|
||||||
|
intent="critical"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.falsePositive')}
|
||||||
|
value={statusCount('FALSE_POSITIVE')}
|
||||||
|
intent="muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('gearCollision.list.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||||
|
<Select
|
||||||
|
aria-label={t('gearCollision.filters.status')}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('gearCollision.filters.allStatus')}</option>
|
||||||
|
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
|
||||||
|
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
aria-label={t('gearCollision.filters.severity')}
|
||||||
|
value={severityFilter}
|
||||||
|
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('gearCollision.filters.allSeverity')}</option>
|
||||||
|
{SEVERITY_OPTIONS.map((sv) => (
|
||||||
|
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
aria-label={t('gearCollision.filters.name')}
|
||||||
|
placeholder={t('gearCollision.filters.name')}
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Badge intent="info" size="sm">
|
||||||
|
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 && !loading ? (
|
||||||
|
<p className="text-hint text-xs py-4 text-center">
|
||||||
|
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={rows as (GearCollision & Record<string, unknown>)[]}
|
||||||
|
columns={cols}
|
||||||
|
pageSize={20}
|
||||||
|
showSearch={false}
|
||||||
|
showExport={false}
|
||||||
|
showPrint={false}
|
||||||
|
onRowClick={(row) => setSelected(row as GearCollision)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{syncedSelected && (
|
||||||
|
<Section title={t('gearCollision.detail.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.mmsiPair')}
|
||||||
|
value={`${syncedSelected.mmsiLo} ↔ ${syncedSelected.mmsiHi}`}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.parentName')}
|
||||||
|
value={syncedSelected.parentName ?? '-'}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.firstSeenAt')}
|
||||||
|
value={formatDateTime(syncedSelected.firstSeenAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.lastSeenAt')}
|
||||||
|
value={formatDateTime(syncedSelected.lastSeenAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.coexistenceCount')}
|
||||||
|
value={String(syncedSelected.coexistenceCount)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.swapCount')}
|
||||||
|
value={String(syncedSelected.swapCount)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.maxDistance')}
|
||||||
|
value={
|
||||||
|
syncedSelected.maxDistanceKm != null
|
||||||
|
? Number(syncedSelected.maxDistanceKm).toFixed(2)
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-label">
|
||||||
|
{t('gearCollision.columns.severity')}:
|
||||||
|
</span>
|
||||||
|
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
|
||||||
|
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-label ml-3">
|
||||||
|
{t('gearCollision.columns.status')}:
|
||||||
|
</span>
|
||||||
|
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
|
||||||
|
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{syncedSelected.resolutionNote && (
|
||||||
|
<p className="text-xs text-hint border-l-2 border-border pl-2">
|
||||||
|
{syncedSelected.resolutionNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="gc-resolve-action"
|
||||||
|
className="block text-xs text-label"
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.title')}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="gc-resolve-action"
|
||||||
|
aria-label={t('gearCollision.resolve.title')}
|
||||||
|
value={resolveAction}
|
||||||
|
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
|
||||||
|
>
|
||||||
|
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
|
||||||
|
<option value="CONFIRMED_ILLEGAL">
|
||||||
|
{t('gearCollision.resolve.confirmedIllegal')}
|
||||||
|
</option>
|
||||||
|
<option value="FALSE_POSITIVE">
|
||||||
|
{t('gearCollision.resolve.falsePositive')}
|
||||||
|
</option>
|
||||||
|
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
aria-label={t('gearCollision.resolve.note')}
|
||||||
|
placeholder={t('gearCollision.resolve.notePlaceholder')}
|
||||||
|
value={resolveNote}
|
||||||
|
onChange={(e) => setResolveNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setSelected(null); setResolveNote(''); }}
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving}
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 컴포넌트 ─────────────
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, intent }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-hint">{label}</span>
|
||||||
|
{intent ? (
|
||||||
|
<Badge intent={intent} size="md">
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-heading">{value}</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||||
|
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GearCollisionDetection;
|
||||||
@ -2,3 +2,4 @@ export { DarkVesselDetection } from './DarkVesselDetection';
|
|||||||
export { GearDetection } from './GearDetection';
|
export { GearDetection } from './GearDetection';
|
||||||
export { ChinaFishing } from './ChinaFishing';
|
export { ChinaFishing } from './ChinaFishing';
|
||||||
export { GearIdentification } from './GearIdentification';
|
export { GearIdentification } from './GearIdentification';
|
||||||
|
export { GearCollisionDetection } from './GearCollisionDetection';
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"darkVessel": "Dark Vessel",
|
"darkVessel": "Dark Vessel",
|
||||||
"gearDetection": "Gear Detection",
|
"gearDetection": "Gear Detection",
|
||||||
"chinaFishing": "Chinese Vessel",
|
"chinaFishing": "Chinese Vessel",
|
||||||
|
"gearCollision": "Gear Collision",
|
||||||
"patrolRoute": "Patrol Route",
|
"patrolRoute": "Patrol Route",
|
||||||
"fleetOptimization": "Fleet Optimize",
|
"fleetOptimization": "Fleet Optimize",
|
||||||
"enforcementHistory": "History",
|
"enforcementHistory": "History",
|
||||||
|
|||||||
@ -14,5 +14,77 @@
|
|||||||
"gearId": {
|
"gearId": {
|
||||||
"title": "Gear Identification",
|
"title": "Gear Identification",
|
||||||
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||||
|
},
|
||||||
|
"gearCollision": {
|
||||||
|
"title": "Gear Identity Collision",
|
||||||
|
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
|
||||||
|
"stats": {
|
||||||
|
"title": "Overview",
|
||||||
|
"total": "Total",
|
||||||
|
"open": "Open",
|
||||||
|
"reviewed": "Reviewed",
|
||||||
|
"confirmed": "Confirmed Illegal",
|
||||||
|
"falsePositive": "False Positive"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Collision Log",
|
||||||
|
"empty": "No collisions detected in the last {{hours}} hours.",
|
||||||
|
"refresh": "Refresh"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "Gear Name",
|
||||||
|
"mmsiPair": "MMSI Pair",
|
||||||
|
"parentName": "Parent Vessel (est.)",
|
||||||
|
"coexistenceCount": "Coexistence",
|
||||||
|
"maxDistance": "Max Distance (km)",
|
||||||
|
"severity": "Severity",
|
||||||
|
"status": "Status",
|
||||||
|
"lastSeen": "Last Seen",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"status": "Status",
|
||||||
|
"severity": "Severity",
|
||||||
|
"name": "Search by name",
|
||||||
|
"hours": "Window (hours)",
|
||||||
|
"allStatus": "All statuses",
|
||||||
|
"allSeverity": "All severities"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Collision Detail",
|
||||||
|
"evidence": "Observations",
|
||||||
|
"trajectoryCompare": "Trajectory Compare",
|
||||||
|
"firstSeenAt": "First Seen",
|
||||||
|
"lastSeenAt": "Last Seen",
|
||||||
|
"swapCount": "Swap Count"
|
||||||
|
},
|
||||||
|
"resolve": {
|
||||||
|
"title": "Operator Review",
|
||||||
|
"reviewed": "Mark as Reviewed",
|
||||||
|
"confirmedIllegal": "Confirm Illegal",
|
||||||
|
"falsePositive": "Mark False Positive",
|
||||||
|
"reopen": "Reopen",
|
||||||
|
"note": "Note",
|
||||||
|
"notePlaceholder": "Record rationale or supporting evidence",
|
||||||
|
"submit": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirmPrompt": "Update status to the selected classification. Continue?"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "Open",
|
||||||
|
"reviewed": "Reviewed",
|
||||||
|
"confirmedIllegal": "Confirmed Illegal",
|
||||||
|
"falsePositive": "False Positive"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"CRITICAL": "Critical",
|
||||||
|
"HIGH": "High",
|
||||||
|
"MEDIUM": "Medium",
|
||||||
|
"LOW": "Low"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "Failed to load collisions",
|
||||||
|
"resolveFailed": "Failed to save classification"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"darkVessel": "다크베셀 탐지",
|
"darkVessel": "다크베셀 탐지",
|
||||||
"gearDetection": "어구 탐지",
|
"gearDetection": "어구 탐지",
|
||||||
"chinaFishing": "중국어선 분석",
|
"chinaFishing": "중국어선 분석",
|
||||||
|
"gearCollision": "어구 정체성 충돌",
|
||||||
"patrolRoute": "순찰경로 추천",
|
"patrolRoute": "순찰경로 추천",
|
||||||
"fleetOptimization": "다함정 최적화",
|
"fleetOptimization": "다함정 최적화",
|
||||||
"enforcementHistory": "단속 이력",
|
"enforcementHistory": "단속 이력",
|
||||||
|
|||||||
@ -14,5 +14,77 @@
|
|||||||
"gearId": {
|
"gearId": {
|
||||||
"title": "어구 식별 분석",
|
"title": "어구 식별 분석",
|
||||||
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
||||||
|
},
|
||||||
|
"gearCollision": {
|
||||||
|
"title": "어구 정체성 충돌 탐지",
|
||||||
|
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
|
||||||
|
"stats": {
|
||||||
|
"title": "현황 요약",
|
||||||
|
"total": "전체",
|
||||||
|
"open": "미검토",
|
||||||
|
"reviewed": "검토됨",
|
||||||
|
"confirmed": "불법 확정",
|
||||||
|
"falsePositive": "오탐"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "충돌 이력",
|
||||||
|
"empty": "최근 {{hours}}시간 내 감지된 충돌이 없습니다.",
|
||||||
|
"refresh": "새로고침"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "어구명",
|
||||||
|
"mmsiPair": "MMSI 쌍",
|
||||||
|
"parentName": "추정 모선",
|
||||||
|
"coexistenceCount": "공존 횟수",
|
||||||
|
"maxDistance": "최대 거리(km)",
|
||||||
|
"severity": "심각도",
|
||||||
|
"status": "상태",
|
||||||
|
"lastSeen": "마지막 감지",
|
||||||
|
"actions": "액션"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"status": "상태",
|
||||||
|
"severity": "심각도",
|
||||||
|
"name": "어구명 검색",
|
||||||
|
"hours": "조회 기간(시간)",
|
||||||
|
"allStatus": "전체 상태",
|
||||||
|
"allSeverity": "전체 심각도"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "공존 상세",
|
||||||
|
"evidence": "관측 이력",
|
||||||
|
"trajectoryCompare": "궤적 비교",
|
||||||
|
"firstSeenAt": "최초 감지",
|
||||||
|
"lastSeenAt": "마지막 감지",
|
||||||
|
"swapCount": "교체 누적"
|
||||||
|
},
|
||||||
|
"resolve": {
|
||||||
|
"title": "운영자 분류",
|
||||||
|
"reviewed": "검토 완료",
|
||||||
|
"confirmedIllegal": "불법으로 확정",
|
||||||
|
"falsePositive": "오탐으로 분류",
|
||||||
|
"reopen": "재오픈",
|
||||||
|
"note": "판정 메모",
|
||||||
|
"notePlaceholder": "분류 사유·추가 증거 등을 기록하세요",
|
||||||
|
"submit": "저장",
|
||||||
|
"cancel": "취소",
|
||||||
|
"confirmPrompt": "선택한 분류로 상태를 갱신합니다. 계속할까요?"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "미검토",
|
||||||
|
"reviewed": "검토됨",
|
||||||
|
"confirmedIllegal": "불법 확정",
|
||||||
|
"falsePositive": "오탐"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"CRITICAL": "매우 심각",
|
||||||
|
"HIGH": "심각",
|
||||||
|
"MEDIUM": "주의",
|
||||||
|
"LOW": "경미"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "충돌 목록을 불러오지 못했습니다",
|
||||||
|
"resolveFailed": "분류 저장에 실패했습니다"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
frontend/src/services/gearCollisionApi.ts
Normal file
114
frontend/src/services/gearCollisionApi.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* gear_identity_collisions 조회 + 분류 액션 API 서비스.
|
||||||
|
* 백엔드 /api/analysis/gear-collisions 연동.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AnalysisPageResponse } from './analysisApi';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
|
|
||||||
|
// ─── DTO (백엔드 GearCollisionResponse 1:1 매핑) ─────────────
|
||||||
|
|
||||||
|
export interface GearCollision {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
mmsiLo: string;
|
||||||
|
mmsiHi: string;
|
||||||
|
parentName: string | null;
|
||||||
|
parentVesselId: number | null;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
coexistenceCount: number;
|
||||||
|
swapCount: number;
|
||||||
|
maxDistanceKm: number | null;
|
||||||
|
lastLatLo: number | null;
|
||||||
|
lastLonLo: number | null;
|
||||||
|
lastLatHi: number | null;
|
||||||
|
lastLonHi: number | null;
|
||||||
|
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | string;
|
||||||
|
status: 'OPEN' | 'REVIEWED' | 'CONFIRMED_ILLEGAL' | 'FALSE_POSITIVE' | string;
|
||||||
|
resolutionNote: string | null;
|
||||||
|
evidence: Array<Record<string, unknown>> | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GearCollisionStats {
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
bySeverity: Record<string, number>;
|
||||||
|
hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GearCollisionResolveAction =
|
||||||
|
| 'REVIEWED'
|
||||||
|
| 'CONFIRMED_ILLEGAL'
|
||||||
|
| 'FALSE_POSITIVE'
|
||||||
|
| 'REOPEN';
|
||||||
|
|
||||||
|
export interface GearCollisionResolveRequest {
|
||||||
|
action: GearCollisionResolveAction;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 헬퍼 ─────────────
|
||||||
|
|
||||||
|
function buildQuery(params: Record<string, unknown>): string {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null || v === '') continue;
|
||||||
|
qs.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = qs.toString();
|
||||||
|
return s ? `?${s}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 공개 함수 ─────────────
|
||||||
|
|
||||||
|
/** 어구 정체성 충돌 목록 조회 (필터 + 페이징). */
|
||||||
|
export function listGearCollisions(params?: {
|
||||||
|
status?: string;
|
||||||
|
severity?: string;
|
||||||
|
name?: string;
|
||||||
|
hours?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<AnalysisPageResponse<GearCollision>> {
|
||||||
|
return apiGet('/analysis/gear-collisions', {
|
||||||
|
hours: 48, page: 0, size: 50, ...params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** status/severity 집계 */
|
||||||
|
export function getGearCollisionStats(hours = 48): Promise<GearCollisionStats> {
|
||||||
|
return apiGet('/analysis/gear-collisions/stats', { hours });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 상세 조회 */
|
||||||
|
export function getGearCollision(id: number): Promise<GearCollision> {
|
||||||
|
return apiGet(`/analysis/gear-collisions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 운영자 분류 액션 */
|
||||||
|
export function resolveGearCollision(
|
||||||
|
id: number,
|
||||||
|
body: GearCollisionResolveRequest,
|
||||||
|
): Promise<GearCollision> {
|
||||||
|
return apiPost(`/analysis/gear-collisions/${id}/resolve`, body);
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ import { ZONE_CODES } from './zoneCodes';
|
|||||||
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
||||||
import { VESSEL_TYPES } from './vesselTypes';
|
import { VESSEL_TYPES } from './vesselTypes';
|
||||||
import { PERFORMANCE_STATUS_META } from './performanceStatus';
|
import { PERFORMANCE_STATUS_META } from './performanceStatus';
|
||||||
|
import { GEAR_COLLISION_STATUSES } from './gearCollisionStatuses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
||||||
@ -337,6 +338,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
|||||||
source: 'admin 성능·데이터 보관·모델 검증 공통',
|
source: 'admin 성능·데이터 보관·모델 검증 공통',
|
||||||
items: PERFORMANCE_STATUS_META,
|
items: PERFORMANCE_STATUS_META,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'gear-collision-status',
|
||||||
|
showcaseId: 'TRK-CAT-gear-collision-status',
|
||||||
|
titleKo: '어구 정체성 충돌 상태',
|
||||||
|
titleEn: 'Gear Identity Collision Status',
|
||||||
|
description: 'OPEN / REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE',
|
||||||
|
source: 'backend gear_identity_collisions.status (V030)',
|
||||||
|
items: GEAR_COLLISION_STATUSES,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** ID로 특정 카탈로그 조회 */
|
/** ID로 특정 카탈로그 조회 */
|
||||||
|
|||||||
74
frontend/src/shared/constants/gearCollisionStatuses.ts
Normal file
74
frontend/src/shared/constants/gearCollisionStatuses.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 운영자 분류 상태 카탈로그
|
||||||
|
*
|
||||||
|
* SSOT: backend GearIdentityCollision.status 컬럼 (V030 마이그레이션)
|
||||||
|
* 사용처: GearCollisionDetection 페이지 필터/테이블 Badge
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
|
|
||||||
|
export type GearCollisionStatus =
|
||||||
|
| 'OPEN' // 신규 탐지 (미검토)
|
||||||
|
| 'REVIEWED' // 검토됨 (확정 보류)
|
||||||
|
| 'CONFIRMED_ILLEGAL' // 불법 확정
|
||||||
|
| 'FALSE_POSITIVE'; // 오탐 처리
|
||||||
|
|
||||||
|
export interface GearCollisionStatusMeta {
|
||||||
|
code: GearCollisionStatus;
|
||||||
|
i18nKey: string;
|
||||||
|
fallback: { ko: string; en: string };
|
||||||
|
intent: BadgeIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GEAR_COLLISION_STATUSES: Record<GearCollisionStatus, GearCollisionStatusMeta> = {
|
||||||
|
OPEN: {
|
||||||
|
code: 'OPEN',
|
||||||
|
i18nKey: 'gearCollision.status.open',
|
||||||
|
fallback: { ko: '미검토', en: 'Open' },
|
||||||
|
intent: 'warning',
|
||||||
|
},
|
||||||
|
REVIEWED: {
|
||||||
|
code: 'REVIEWED',
|
||||||
|
i18nKey: 'gearCollision.status.reviewed',
|
||||||
|
fallback: { ko: '검토됨', en: 'Reviewed' },
|
||||||
|
intent: 'info',
|
||||||
|
},
|
||||||
|
CONFIRMED_ILLEGAL: {
|
||||||
|
code: 'CONFIRMED_ILLEGAL',
|
||||||
|
i18nKey: 'gearCollision.status.confirmedIllegal',
|
||||||
|
fallback: { ko: '불법 확정', en: 'Confirmed Illegal' },
|
||||||
|
intent: 'critical',
|
||||||
|
},
|
||||||
|
FALSE_POSITIVE: {
|
||||||
|
code: 'FALSE_POSITIVE',
|
||||||
|
i18nKey: 'gearCollision.status.falsePositive',
|
||||||
|
fallback: { ko: '오탐', en: 'False Positive' },
|
||||||
|
intent: 'muted',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getGearCollisionStatusMeta(s: string): GearCollisionStatusMeta | undefined {
|
||||||
|
return GEAR_COLLISION_STATUSES[s as GearCollisionStatus];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGearCollisionStatusIntent(s: string): BadgeIntent {
|
||||||
|
return getGearCollisionStatusMeta(s)?.intent ?? 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGearCollisionStatusLabel(
|
||||||
|
s: string,
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||||
|
lang: 'ko' | 'en' = 'ko',
|
||||||
|
): string {
|
||||||
|
const meta = getGearCollisionStatusMeta(s);
|
||||||
|
if (!meta) return s;
|
||||||
|
const translated = t(meta.i18nKey, { defaultValue: '' });
|
||||||
|
return translated || meta.fallback[lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GEAR_COLLISION_STATUS_ORDER: GearCollisionStatus[] = [
|
||||||
|
'OPEN',
|
||||||
|
'REVIEWED',
|
||||||
|
'CONFIRMED_ILLEGAL',
|
||||||
|
'FALSE_POSITIVE',
|
||||||
|
];
|
||||||
157
prediction/algorithms/gear_identity.py
Normal file
157
prediction/algorithms/gear_identity.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 알고리즘.
|
||||||
|
|
||||||
|
동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는 케이스를
|
||||||
|
스푸핑/복제 의심 패턴으로 탐지한다. fleet_tracker.track_gear_identity() 루프 진입
|
||||||
|
전에 사이클 단위로 사전 집계하는 데 사용된다.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from itertools import combinations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from algorithms.location import haversine_nm
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# 공존 판정 · 심각도 임계
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
MIN_COEXISTENCE_GROUP = 2 # 같은 이름에 MMSI 2개 이상
|
||||||
|
IMPOSSIBLE_SPEED_KTS = 60.0 # 두 위치 이동에 필요한 속도가 이보다 크면 물리 불가능
|
||||||
|
CRITICAL_DISTANCE_KM = 50.0 # 단발이라도 이 거리 이상이면 즉시 CRITICAL
|
||||||
|
HIGH_DISTANCE_KM = 10.0 # HIGH 기준 거리
|
||||||
|
CRITICAL_COEXISTENCE_COUNT = 3 # 누적 공존 N회 이상이면 CRITICAL 승격
|
||||||
|
HIGH_COEXISTENCE_COUNT = 2 # 누적 공존 N회 이상이면 HIGH
|
||||||
|
NM_TO_KM = 1.852 # 1 nautical mile = 1.852 km
|
||||||
|
|
||||||
|
|
||||||
|
def detect_gear_name_collisions(
|
||||||
|
gear_signals: list[dict],
|
||||||
|
now: datetime,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""동일 이름 · 다중 MMSI 공존 세트 추출.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gear_signals: [{mmsi, name, lat, lon}, ...] — track_gear_identity 와 동일 입력.
|
||||||
|
now: 사이클 기준 시각(UTC).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
공존 쌍 리스트. 세 개 이상 동시 송출 케이스는 모든 2-조합을 생성한다.
|
||||||
|
각 원소:
|
||||||
|
{
|
||||||
|
'name': str,
|
||||||
|
'mmsi_lo': str, # 사전순으로 작은 MMSI
|
||||||
|
'mmsi_hi': str,
|
||||||
|
'lat_lo', 'lon_lo': float,
|
||||||
|
'lat_hi', 'lon_hi': float,
|
||||||
|
'distance_km': float,
|
||||||
|
'parent_name': Optional[str], # 힌트 (GEAR_PATTERN parent 그룹, 있으면)
|
||||||
|
'observed_at': datetime,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not gear_signals:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 이름 기준 그룹핑
|
||||||
|
by_name: dict[str, list[dict]] = {}
|
||||||
|
for sig in gear_signals:
|
||||||
|
name = sig.get('name')
|
||||||
|
mmsi = sig.get('mmsi')
|
||||||
|
if not name or not mmsi:
|
||||||
|
continue
|
||||||
|
by_name.setdefault(name, []).append(sig)
|
||||||
|
|
||||||
|
collisions: list[dict] = []
|
||||||
|
for name, signals in by_name.items():
|
||||||
|
if len(signals) < MIN_COEXISTENCE_GROUP:
|
||||||
|
continue
|
||||||
|
# 같은 MMSI 중복은 제거 (한 cycle 에 동일 MMSI 가 다수 신호로 들어올 수 있음)
|
||||||
|
unique_by_mmsi: dict[str, dict] = {}
|
||||||
|
for sig in signals:
|
||||||
|
unique_by_mmsi.setdefault(sig['mmsi'], sig)
|
||||||
|
if len(unique_by_mmsi) < MIN_COEXISTENCE_GROUP:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parent_name = _infer_parent_name(name)
|
||||||
|
mmsis = sorted(unique_by_mmsi.keys())
|
||||||
|
for a, b in combinations(mmsis, 2):
|
||||||
|
sa, sb = unique_by_mmsi[a], unique_by_mmsi[b]
|
||||||
|
dist_km = _haversine_km(
|
||||||
|
sa.get('lat'), sa.get('lon'),
|
||||||
|
sb.get('lat'), sb.get('lon'),
|
||||||
|
)
|
||||||
|
collisions.append({
|
||||||
|
'name': name,
|
||||||
|
'mmsi_lo': a,
|
||||||
|
'mmsi_hi': b,
|
||||||
|
'lat_lo': _to_float(sa.get('lat')),
|
||||||
|
'lon_lo': _to_float(sa.get('lon')),
|
||||||
|
'lat_hi': _to_float(sb.get('lat')),
|
||||||
|
'lon_hi': _to_float(sb.get('lon')),
|
||||||
|
'distance_km': dist_km,
|
||||||
|
'parent_name': parent_name,
|
||||||
|
'observed_at': now,
|
||||||
|
})
|
||||||
|
return collisions
|
||||||
|
|
||||||
|
|
||||||
|
def classify_severity(
|
||||||
|
coexistence_count: int,
|
||||||
|
max_distance_km: Optional[float],
|
||||||
|
swap_count: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""충돌 심각도 산정.
|
||||||
|
|
||||||
|
- CRITICAL: 거리 불가능 / 누적 공존 N회 이상
|
||||||
|
- HIGH: 상당 거리 / 2회 이상
|
||||||
|
- MEDIUM: 단발 근거리
|
||||||
|
- LOW: 근거리 + 거리 정보 없음
|
||||||
|
"""
|
||||||
|
distance = max_distance_km or 0.0
|
||||||
|
if distance >= CRITICAL_DISTANCE_KM:
|
||||||
|
return 'CRITICAL'
|
||||||
|
if coexistence_count >= CRITICAL_COEXISTENCE_COUNT:
|
||||||
|
return 'CRITICAL'
|
||||||
|
if distance >= HIGH_DISTANCE_KM:
|
||||||
|
return 'HIGH'
|
||||||
|
if coexistence_count >= HIGH_COEXISTENCE_COUNT:
|
||||||
|
return 'HIGH'
|
||||||
|
if swap_count >= HIGH_COEXISTENCE_COUNT:
|
||||||
|
return 'HIGH'
|
||||||
|
if max_distance_km is None or max_distance_km < 0.1:
|
||||||
|
return 'LOW'
|
||||||
|
return 'MEDIUM'
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||||
|
"""두 좌표 사이 거리를 km 로 반환. 입력 누락 시 0.0."""
|
||||||
|
try:
|
||||||
|
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
|
||||||
|
return 0.0
|
||||||
|
nm = haversine_nm(float(lat1), float(lon1), float(lat2), float(lon2))
|
||||||
|
return round(nm * NM_TO_KM, 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(val) -> Optional[float]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_parent_name(gear_name: str) -> Optional[str]:
|
||||||
|
"""어구 이름에서 모선명 부분 추출 (느슨).
|
||||||
|
|
||||||
|
fleet_tracker 가 이미 GEAR_PATTERN 으로 정교하게 파싱하지만, 알고리즘 모듈 독립성을
|
||||||
|
위해 단순 휴리스틱만 유지한다. 값이 필요한 경우 fleet_tracker 호출부에서 덮어쓴다.
|
||||||
|
"""
|
||||||
|
if not gear_name:
|
||||||
|
return None
|
||||||
|
# '_숫자' 로 끝나는 서픽스 제거
|
||||||
|
base = gear_name
|
||||||
|
parts = base.rsplit('_', 2)
|
||||||
|
if len(parts) >= 2 and any(ch.isdigit() for ch in parts[-1]):
|
||||||
|
return parts[0]
|
||||||
|
return None
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"""등록 선단 기반 추적기."""
|
"""등록 선단 기반 추적기."""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@ -6,7 +7,9 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
from algorithms.gear_identity import classify_severity, detect_gear_name_collisions
|
||||||
from algorithms.gear_name_rules import is_trackable_parent_name
|
from algorithms.gear_name_rules import is_trackable_parent_name
|
||||||
from config import qualified_table
|
from config import qualified_table
|
||||||
|
|
||||||
@ -21,6 +24,7 @@ FLEET_COMPANIES = qualified_table('fleet_companies')
|
|||||||
FLEET_VESSELS = qualified_table('fleet_vessels')
|
FLEET_VESSELS = qualified_table('fleet_vessels')
|
||||||
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
|
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
|
||||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||||
|
GEAR_IDENTITY_COLLISIONS = qualified_table('gear_identity_collisions')
|
||||||
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
|
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
|
||||||
|
|
||||||
# 선박명 정규화 (중국/한국 어선 식별자 보존):
|
# 선박명 정규화 (중국/한국 어선 식별자 보존):
|
||||||
@ -341,61 +345,40 @@ class FleetTracker:
|
|||||||
"""어구/어망 정체성 추적.
|
"""어구/어망 정체성 추적.
|
||||||
|
|
||||||
gear_signals: [{mmsi, name, lat, lon}, ...] — 이름이 XXX_숫자_숫자 패턴인 AIS 신호
|
gear_signals: [{mmsi, name, lat, lon}, ...] — 이름이 XXX_숫자_숫자 패턴인 AIS 신호
|
||||||
|
|
||||||
|
동일 이름이 서로 다른 MMSI 로 같은 cycle 에 동시에 수신된 경우는 "공존
|
||||||
|
(GEAR_IDENTITY_COLLISION)" 으로 간주해 gear_identity_collisions 에 누적하고,
|
||||||
|
gear_identity_log 는 각 MMSI 별로 독립 active 유지한다 (비활성화/점수 이전 X).
|
||||||
|
단일 MMSI 이름에 한해 기존 교체(sequential) 로직을 적용한다.
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
self._recent_collision_ids: list[int] = []
|
||||||
|
|
||||||
|
# 1) 공존 감지 + UPSERT (이번 사이클에 동일 이름 다중 MMSI 수신 확인)
|
||||||
|
collisions = detect_gear_name_collisions(gear_signals, now)
|
||||||
|
colliding_names: set[str] = {c['name'] for c in collisions}
|
||||||
|
for c in collisions:
|
||||||
|
cid = self._upsert_gear_collision(cur, c, now)
|
||||||
|
if cid is not None:
|
||||||
|
self._recent_collision_ids.append(cid)
|
||||||
|
|
||||||
|
# 2) 개별 신호 처리
|
||||||
for g in gear_signals:
|
for g in gear_signals:
|
||||||
mmsi = g['mmsi']
|
mmsi = g['mmsi']
|
||||||
name = g['name']
|
name = g['name']
|
||||||
lat = g.get('lat', 0)
|
lat = g.get('lat', 0)
|
||||||
lon = g.get('lon', 0)
|
lon = g.get('lon', 0)
|
||||||
|
|
||||||
# 모선명 + 인덱스 추출
|
parent_name, idx1, idx2 = self._parse_gear_name(name)
|
||||||
parent_name: Optional[str] = None
|
|
||||||
idx1: Optional[int] = None
|
|
||||||
idx2: Optional[int] = None
|
|
||||||
|
|
||||||
m = GEAR_PATTERN.match(name)
|
|
||||||
if m:
|
|
||||||
# group(1): parent+index 패턴, group(2): 순수 숫자 패턴
|
|
||||||
if m.group(1):
|
|
||||||
parent_name = m.group(1).strip()
|
|
||||||
suffix = name[m.end(1):].strip(' _')
|
|
||||||
digits = re.findall(r'\d+', suffix)
|
|
||||||
idx1 = int(digits[0]) if len(digits) >= 1 else None
|
|
||||||
idx2 = int(digits[1]) if len(digits) >= 2 else None
|
|
||||||
else:
|
|
||||||
# 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만
|
|
||||||
idx1 = int(m.group(2))
|
|
||||||
else:
|
|
||||||
m2 = GEAR_PATTERN_PCT.match(name)
|
|
||||||
if m2:
|
|
||||||
parent_name = m2.group(1).strip()
|
|
||||||
|
|
||||||
effective_parent_name = parent_name or name
|
effective_parent_name = parent_name or name
|
||||||
if not is_trackable_parent_name(effective_parent_name):
|
if not is_trackable_parent_name(effective_parent_name):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 모선 매칭 (EXACT → FUZZY 순)
|
parent_mmsi, parent_vid = self._match_parent_vessel(parent_name)
|
||||||
parent_mmsi: Optional[str] = None
|
|
||||||
parent_vid: Optional[int] = None
|
|
||||||
if parent_name:
|
|
||||||
vid = self._name_cn_map.get(parent_name)
|
|
||||||
if not vid:
|
|
||||||
vid = self._name_en_map.get(parent_name.lower())
|
|
||||||
if not vid:
|
|
||||||
key = _normalize_vessel_name(parent_name)
|
|
||||||
if key:
|
|
||||||
candidates = self._name_fuzzy_map.get(key, [])
|
|
||||||
if len(candidates) == 1:
|
|
||||||
vid = candidates[0]
|
|
||||||
if vid:
|
|
||||||
parent_vid = vid
|
|
||||||
parent_mmsi = self._vessels[vid].get('mmsi')
|
|
||||||
|
|
||||||
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
|
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
|
||||||
confidence = 0.9 if parent_vid else 0.0
|
confidence = 0.9 if parent_vid else 0.0
|
||||||
|
is_colliding = name in colliding_names
|
||||||
|
|
||||||
# 기존 활성 행 조회
|
# 기존 활성 행 조회
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@ -431,7 +414,9 @@ class FleetTracker:
|
|||||||
match_method, confidence, now, now),
|
match_method, confidence, now, now),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인
|
# 새 MMSI — 이름이 공존 케이스면 다른 MMSI 활성행을 건드리지 않고 이번 것만 INSERT
|
||||||
|
if not is_colliding:
|
||||||
|
# 교체 경로 — 같은 이름이 다른 MMSI 로 active 면 MMSI 교체로 간주
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
|
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
|
||||||
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
||||||
@ -439,28 +424,16 @@ class FleetTracker:
|
|||||||
)
|
)
|
||||||
old_mmsi_row = cur.fetchone()
|
old_mmsi_row = cur.fetchone()
|
||||||
if old_mmsi_row:
|
if old_mmsi_row:
|
||||||
# 같은 이름 + 다른 MMSI → MMSI 변경
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
||||||
(old_mmsi_row[0],),
|
(old_mmsi_row[0],),
|
||||||
)
|
)
|
||||||
logger.info('gear MMSI change: %s → %s (name=%s)', old_mmsi_row[1], mmsi, name)
|
|
||||||
|
|
||||||
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE {GEAR_CORRELATION_SCORES} "
|
|
||||||
"SET target_mmsi = %s, updated_at = NOW() "
|
|
||||||
"WHERE target_mmsi = %s",
|
|
||||||
(mmsi, old_mmsi_row[1]),
|
|
||||||
)
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'transferred %d affinity scores: %s → %s',
|
'gear MMSI change: %s → %s (name=%s)',
|
||||||
cur.rowcount, old_mmsi_row[1], mmsi,
|
old_mmsi_row[1], mmsi, name,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
# 어피니티 점수 이전 (savepoint 로 격리 — PK 충돌 시 트랜잭션 유지)
|
||||||
logger.warning('affinity score transfer failed: %s', e)
|
self._transfer_affinity_scores(cur, old_mmsi_row[1], mmsi)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""INSERT INTO {GEAR_IDENTITY_LOG}
|
f"""INSERT INTO {GEAR_IDENTITY_LOG}
|
||||||
@ -473,9 +446,190 @@ class FleetTracker:
|
|||||||
match_method, confidence, now, now),
|
match_method, confidence, now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if collisions:
|
||||||
|
logger.info(
|
||||||
|
'gear identity collisions: %d pairs touched (%d distinct names)',
|
||||||
|
len(collisions), len(colliding_names),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
|
def _parse_gear_name(self, name: str) -> tuple[Optional[str], Optional[int], Optional[int]]:
|
||||||
|
"""어구 이름에서 parent_name, idx1, idx2 추출."""
|
||||||
|
parent_name: Optional[str] = None
|
||||||
|
idx1: Optional[int] = None
|
||||||
|
idx2: Optional[int] = None
|
||||||
|
m = GEAR_PATTERN.match(name)
|
||||||
|
if m:
|
||||||
|
if m.group(1):
|
||||||
|
parent_name = m.group(1).strip()
|
||||||
|
suffix = name[m.end(1):].strip(' _')
|
||||||
|
digits = re.findall(r'\d+', suffix)
|
||||||
|
idx1 = int(digits[0]) if len(digits) >= 1 else None
|
||||||
|
idx2 = int(digits[1]) if len(digits) >= 2 else None
|
||||||
|
else:
|
||||||
|
idx1 = int(m.group(2))
|
||||||
|
else:
|
||||||
|
m2 = GEAR_PATTERN_PCT.match(name)
|
||||||
|
if m2:
|
||||||
|
parent_name = m2.group(1).strip()
|
||||||
|
return parent_name, idx1, idx2
|
||||||
|
|
||||||
|
def _match_parent_vessel(self, parent_name: Optional[str]) -> tuple[Optional[str], Optional[int]]:
|
||||||
|
"""parent_name → (parent_mmsi, parent_vessel_id) 매칭. EXACT → FUZZY 순."""
|
||||||
|
if not parent_name:
|
||||||
|
return None, None
|
||||||
|
vid = self._name_cn_map.get(parent_name)
|
||||||
|
if not vid:
|
||||||
|
vid = self._name_en_map.get(parent_name.lower())
|
||||||
|
if not vid:
|
||||||
|
key = _normalize_vessel_name(parent_name)
|
||||||
|
if key:
|
||||||
|
candidates = self._name_fuzzy_map.get(key, [])
|
||||||
|
if len(candidates) == 1:
|
||||||
|
vid = candidates[0]
|
||||||
|
if vid:
|
||||||
|
return self._vessels[vid].get('mmsi'), vid
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _transfer_affinity_scores(self, cur, old_mmsi: str, new_mmsi: str) -> None:
|
||||||
|
"""gear_correlation_scores 의 target_mmsi 를 새 MMSI 로 이전.
|
||||||
|
|
||||||
|
PK = (model_id, group_key, sub_cluster_id, target_mmsi) 충돌 가능성이 있어
|
||||||
|
SAVEPOINT 로 격리. 충돌 시에는 이전을 포기하고 경고만 남긴다 — 상위 트랜잭션은
|
||||||
|
유지되어 뒤이은 INSERT 가 정상 동작한다.
|
||||||
|
"""
|
||||||
|
cur.execute('SAVEPOINT sp_score_xfer')
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE {GEAR_CORRELATION_SCORES} "
|
||||||
|
"SET target_mmsi = %s, updated_at = NOW() "
|
||||||
|
"WHERE target_mmsi = %s",
|
||||||
|
(new_mmsi, old_mmsi),
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
logger.info(
|
||||||
|
'transferred %d affinity scores: %s → %s',
|
||||||
|
cur.rowcount, old_mmsi, new_mmsi,
|
||||||
|
)
|
||||||
|
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
|
||||||
|
except psycopg2.errors.UniqueViolation as e:
|
||||||
|
cur.execute('ROLLBACK TO SAVEPOINT sp_score_xfer')
|
||||||
|
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
|
||||||
|
logger.warning(
|
||||||
|
'affinity score transfer skipped (pk conflict): %s → %s | %s',
|
||||||
|
old_mmsi, new_mmsi, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _upsert_gear_collision(
|
||||||
|
self,
|
||||||
|
cur,
|
||||||
|
collision: dict,
|
||||||
|
now: datetime,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""단일 공존 쌍 UPSERT → row id 반환.
|
||||||
|
|
||||||
|
- 기존 행: coexistence_count += 1, last_seen_at/좌표 갱신, max_distance_km =
|
||||||
|
GREATEST(기존, 신규). status 가 OPEN/REVIEWED 인 경우에 한해 severity 재계산.
|
||||||
|
- 신규 행: first_seen_at = last_seen_at = now, severity 계산 후 INSERT.
|
||||||
|
"""
|
||||||
|
name = collision['name']
|
||||||
|
mmsi_lo = collision['mmsi_lo']
|
||||||
|
mmsi_hi = collision['mmsi_hi']
|
||||||
|
distance_km = collision.get('distance_km') or 0.0
|
||||||
|
|
||||||
|
# 모선 힌트 — fleet_tracker 매칭 결과를 우선, 없으면 알고리즘이 추출한 값 사용
|
||||||
|
hint_parent = collision.get('parent_name')
|
||||||
|
parent_mmsi, parent_vid = self._match_parent_vessel(hint_parent)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""SELECT id, coexistence_count, max_distance_km, swap_count,
|
||||||
|
status, parent_name, parent_vessel_id
|
||||||
|
FROM {GEAR_IDENTITY_COLLISIONS}
|
||||||
|
WHERE name = %s AND mmsi_lo = %s AND mmsi_hi = %s""",
|
||||||
|
(name, mmsi_lo, mmsi_hi),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
evidence_fragment = {
|
||||||
|
'observed_at': now.isoformat(),
|
||||||
|
'distance_km': distance_km,
|
||||||
|
'positions': {
|
||||||
|
mmsi_lo: {
|
||||||
|
'lat': collision.get('lat_lo'),
|
||||||
|
'lon': collision.get('lon_lo'),
|
||||||
|
},
|
||||||
|
mmsi_hi: {
|
||||||
|
'lat': collision.get('lat_hi'),
|
||||||
|
'lon': collision.get('lon_hi'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if row:
|
||||||
|
(row_id, cur_count, cur_max_dist, swap_cnt,
|
||||||
|
cur_status, cur_parent_name, cur_parent_vid) = row
|
||||||
|
new_count = (cur_count or 0) + 1
|
||||||
|
merged_max = max(
|
||||||
|
float(cur_max_dist or 0),
|
||||||
|
float(distance_km or 0),
|
||||||
|
)
|
||||||
|
if cur_status in ('CONFIRMED_ILLEGAL', 'FALSE_POSITIVE'):
|
||||||
|
new_severity = None # 확정 상태는 유지
|
||||||
|
else:
|
||||||
|
new_severity = classify_severity(new_count, merged_max, swap_cnt or 0)
|
||||||
|
cur.execute(
|
||||||
|
f"""UPDATE {GEAR_IDENTITY_COLLISIONS}
|
||||||
|
SET last_seen_at = %s,
|
||||||
|
coexistence_count = %s,
|
||||||
|
max_distance_km = %s,
|
||||||
|
last_lat_lo = %s,
|
||||||
|
last_lon_lo = %s,
|
||||||
|
last_lat_hi = %s,
|
||||||
|
last_lon_hi = %s,
|
||||||
|
parent_name = COALESCE(%s, parent_name),
|
||||||
|
parent_vessel_id = COALESCE(%s, parent_vessel_id),
|
||||||
|
severity = COALESCE(%s, severity),
|
||||||
|
evidence = COALESCE(evidence, '[]'::jsonb)
|
||||||
|
|| to_jsonb(%s::jsonb),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s""",
|
||||||
|
(
|
||||||
|
now, new_count, merged_max,
|
||||||
|
collision.get('lat_lo'), collision.get('lon_lo'),
|
||||||
|
collision.get('lat_hi'), collision.get('lon_hi'),
|
||||||
|
hint_parent, parent_vid,
|
||||||
|
new_severity,
|
||||||
|
json.dumps([evidence_fragment]),
|
||||||
|
row_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return row_id
|
||||||
|
|
||||||
|
severity = classify_severity(1, distance_km, 0)
|
||||||
|
cur.execute(
|
||||||
|
f"""INSERT INTO {GEAR_IDENTITY_COLLISIONS}
|
||||||
|
(name, mmsi_lo, mmsi_hi, parent_name, parent_vessel_id,
|
||||||
|
first_seen_at, last_seen_at,
|
||||||
|
coexistence_count, max_distance_km,
|
||||||
|
last_lat_lo, last_lon_lo, last_lat_hi, last_lon_hi,
|
||||||
|
severity, status, evidence)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,1,%s,%s,%s,%s,%s,%s,'OPEN',%s)
|
||||||
|
RETURNING id""",
|
||||||
|
(
|
||||||
|
name, mmsi_lo, mmsi_hi, hint_parent, parent_vid,
|
||||||
|
now, now, distance_km,
|
||||||
|
collision.get('lat_lo'), collision.get('lon_lo'),
|
||||||
|
collision.get('lat_hi'), collision.get('lon_hi'),
|
||||||
|
severity,
|
||||||
|
json.dumps([evidence_fragment]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
def get_recent_collision_ids(self) -> list[int]:
|
||||||
|
"""이번 사이클에 갱신·추가된 gear_identity_collisions row id 목록."""
|
||||||
|
return list(getattr(self, '_recent_collision_ids', []))
|
||||||
|
|
||||||
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
|
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
|
||||||
"""등록 선단 기준으로 cluster 정보 구성.
|
"""등록 선단 기준으로 cluster 정보 구성.
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from db.kcgdb import get_conn
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
EVENTS_TABLE = qualified_table('prediction_events')
|
EVENTS_TABLE = qualified_table('prediction_events')
|
||||||
|
GEAR_IDENTITY_COLLISIONS_TABLE = qualified_table('gear_identity_collisions')
|
||||||
|
|
||||||
# 카테고리별 dedup 윈도우 (분).
|
# 카테고리별 dedup 윈도우 (분).
|
||||||
# 사이클이 5분 간격이므로 5의 배수를 피해서 boundary 일제 만료 패턴을 회피한다.
|
# 사이클이 5분 간격이므로 5의 배수를 피해서 boundary 일제 만료 패턴을 회피한다.
|
||||||
@ -34,6 +35,7 @@ DEDUP_WINDOWS = {
|
|||||||
'GEAR_ILLEGAL': 367,
|
'GEAR_ILLEGAL': 367,
|
||||||
'AIS_RESUME': 67,
|
'AIS_RESUME': 67,
|
||||||
'HIGH_RISK_VESSEL': 67,
|
'HIGH_RISK_VESSEL': 67,
|
||||||
|
'GEAR_IDENTITY_COLLISION': 367, # 같은 쌍의 반복 공존은 장기 이벤트로 집계
|
||||||
}
|
}
|
||||||
|
|
||||||
# 이벤트 생성 룰
|
# 이벤트 생성 룰
|
||||||
@ -294,3 +296,123 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
|||||||
|
|
||||||
logger.info(f'event_generator: generated={generated}, skipped_dedup={skipped_dedup}')
|
logger.info(f'event_generator: generated={generated}, skipped_dedup={skipped_dedup}')
|
||||||
return {'generated': generated, 'skipped_dedup': skipped_dedup}
|
return {'generated': generated, 'skipped_dedup': skipped_dedup}
|
||||||
|
|
||||||
|
|
||||||
|
_COLLISION_EVENT_LEVELS = {'CRITICAL', 'HIGH'}
|
||||||
|
|
||||||
|
|
||||||
|
def run_gear_identity_collision_events(collision_ids: list[int]) -> dict:
|
||||||
|
"""gear_identity_collisions 의 갱신 행을 prediction_events 로 승격.
|
||||||
|
|
||||||
|
이번 cycle 에 갱신·추가된 row 중 severity 가 CRITICAL/HIGH 이고 status=OPEN 인 것만
|
||||||
|
이벤트 허브에 등록한다. dedup 키는 `{name}:{mmsi_lo}:{mmsi_hi}` 기준으로 장기 윈도우.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collision_ids: fleet_tracker.get_recent_collision_ids() 가 돌려준 id 목록
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{ 'generated': int, 'skipped_dedup': int, 'skipped_low': int }
|
||||||
|
"""
|
||||||
|
if not collision_ids:
|
||||||
|
return {'generated': 0, 'skipped_dedup': 0, 'skipped_low': 0}
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
generated = 0
|
||||||
|
skipped_dedup = 0
|
||||||
|
skipped_low = 0
|
||||||
|
events_to_insert: list[tuple] = []
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
date_str = now.strftime('%Y%m%d')
|
||||||
|
seq = _get_next_seq(conn, date_str)
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
f"""SELECT id, name, mmsi_lo, mmsi_hi, parent_name,
|
||||||
|
coexistence_count, max_distance_km, severity, status,
|
||||||
|
last_lat_lo, last_lon_lo
|
||||||
|
FROM {GEAR_IDENTITY_COLLISIONS_TABLE}
|
||||||
|
WHERE id = ANY(%s)""",
|
||||||
|
(list(collision_ids),),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
for (cid, name, mmsi_lo, mmsi_hi, parent_name,
|
||||||
|
coexist_count, max_dist, severity, status,
|
||||||
|
lat_lo, lon_lo) in rows:
|
||||||
|
if status != 'OPEN':
|
||||||
|
skipped_low += 1
|
||||||
|
continue
|
||||||
|
if severity not in _COLLISION_EVENT_LEVELS:
|
||||||
|
skipped_low += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
category = 'GEAR_IDENTITY_COLLISION'
|
||||||
|
dedup_key = f"{name}:{mmsi_lo}:{mmsi_hi}:{category}"
|
||||||
|
|
||||||
|
if _check_dedup(conn, dedup_key, category, now):
|
||||||
|
skipped_dedup += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_uid = _make_event_uid(now, seq)
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
distance_txt = f"{float(max_dist or 0):.1f}km"
|
||||||
|
title = (
|
||||||
|
f"어구 정체성 충돌: {name} "
|
||||||
|
f"({mmsi_lo} ↔ {mmsi_hi}, 공존 {coexist_count}회, 거리 {distance_txt})"
|
||||||
|
)
|
||||||
|
features = {
|
||||||
|
'collision_id': cid,
|
||||||
|
'name': name,
|
||||||
|
'mmsi_lo': mmsi_lo,
|
||||||
|
'mmsi_hi': mmsi_hi,
|
||||||
|
'coexistence_count': coexist_count,
|
||||||
|
'max_distance_km': float(max_dist or 0),
|
||||||
|
'parent_name': parent_name,
|
||||||
|
}
|
||||||
|
events_to_insert.append((
|
||||||
|
event_uid,
|
||||||
|
now,
|
||||||
|
severity,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
None, # detail
|
||||||
|
mmsi_lo, # 대표 MMSI (검색 편의)
|
||||||
|
parent_name, # vessel_name
|
||||||
|
None, # area_name
|
||||||
|
None, # zone_code
|
||||||
|
float(lat_lo) if lat_lo is not None else None,
|
||||||
|
float(lon_lo) if lon_lo is not None else None,
|
||||||
|
None, # speed_kn
|
||||||
|
'GEAR_COLLISION', # source_type
|
||||||
|
cid, # source_ref_id
|
||||||
|
None, # ai_confidence
|
||||||
|
'NEW',
|
||||||
|
dedup_key,
|
||||||
|
json.dumps(features, ensure_ascii=False, default=str),
|
||||||
|
))
|
||||||
|
generated += 1
|
||||||
|
|
||||||
|
if events_to_insert:
|
||||||
|
execute_values(
|
||||||
|
conn.cursor(),
|
||||||
|
f"""INSERT INTO {EVENTS_TABLE}
|
||||||
|
(event_uid, occurred_at, level, category, title, detail,
|
||||||
|
vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn,
|
||||||
|
source_type, source_ref_id, ai_confidence, status, dedup_key, features)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT (event_uid) DO NOTHING""",
|
||||||
|
events_to_insert,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'gear_collision_events: generated=%d, skipped_dedup=%d, skipped_low=%d',
|
||||||
|
generated, skipped_dedup, skipped_low,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'generated': generated,
|
||||||
|
'skipped_dedup': skipped_dedup,
|
||||||
|
'skipped_low': skipped_low,
|
||||||
|
}
|
||||||
|
|||||||
@ -157,6 +157,21 @@ def run_analysis_cycle():
|
|||||||
gear_signals = [v for v in all_ais if GEAR_PATTERN.match(v.get('name', '') or '')]
|
gear_signals = [v for v in all_ais if GEAR_PATTERN.match(v.get('name', '') or '')]
|
||||||
fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
|
fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
|
||||||
|
|
||||||
|
# 이번 사이클에 갱신된 어구 정체성 충돌을 이벤트 허브로 승격 (CRITICAL/HIGH 만)
|
||||||
|
collision_ids = fleet_tracker.get_recent_collision_ids()
|
||||||
|
if collision_ids:
|
||||||
|
try:
|
||||||
|
from output.event_generator import run_gear_identity_collision_events
|
||||||
|
collision_events = run_gear_identity_collision_events(collision_ids)
|
||||||
|
logger.info(
|
||||||
|
'gear collision events: generated=%d, skipped_dedup=%d, skipped_low=%d',
|
||||||
|
collision_events['generated'],
|
||||||
|
collision_events['skipped_dedup'],
|
||||||
|
collision_events['skipped_low'],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('gear collision event promotion failed: %s', e)
|
||||||
|
|
||||||
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
|
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
|
||||||
|
|
||||||
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
|
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user