release: 2026-04-17.2 (5건 커밋) #75

병합
htlee develop 에서 main 로 5 commits 를 머지했습니다 2026-04-17 07:19:52 +09:00
29개의 변경된 파일2282개의 추가작업 그리고 1766개의 파일을 삭제

파일 보기

@ -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;

파일 보기

@ -4,6 +4,18 @@
## [Unreleased] ## [Unreleased]
## [2026-04-17.2]
### 추가
- **어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴** — 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 스푸핑/복제 의심 패턴을 신규 탐지. prediction `fleet_tracker.track_gear_identity()` 가 공존(simultaneous) / 교체(sequential) 경로를 분리해 공존 쌍은 `gear_identity_collisions` 에 UPSERT (누적 공존 횟수, 최대 거리, 양측 좌표, evidence JSONB append). 심각도는 거리/누적/스왑 기반으로 CRITICAL/HIGH/MEDIUM/LOW 자동 재계산, 운영자 확정 상태(CONFIRMED_ILLEGAL/FALSE_POSITIVE)는 보존. CRITICAL/HIGH 승격 시 `prediction_events` 허브에 `GEAR_IDENTITY_COLLISION` 카테고리 등록(dedup 367분). `/api/analysis/gear-collisions` READ + resolve 액션(REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE/REOPEN, `@Auditable GEAR_COLLISION_RESOLVE`). 좌측 메뉴 "어구 정체성 충돌" 자동 노출(nav_sort=950, detection:gear-collision)
- **gearCollisionStatuses 카탈로그**`shared/constants/gearCollisionStatuses.ts` + `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출. OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE 4단계 Badge intent 매핑
### 변경
- **prediction 5분 사이클 안정화**`gear_correlation_scores_pkey` 충돌이 매 사이클 `InFailedSqlTransaction` 을 유발해 이벤트 생성·분석 결과 upsert 가 전부 스킵되던 문제 해소. `gear_correlation_scores``target_mmsi` 이전 쿼리를 SAVEPOINT 로 격리해 PK 충돌 시 트랜잭션 유지. 공존 경로는 이전 시도 자체를 하지 않아 재발 방지
### 문서
- **프로젝트 산출문서 2026-04-17 기준 정비**`architecture.md` shared/components/ui 9개·i18n 네임스페이스 갱신, `sfr-traceability.md` v3.0 전면 재작성(운영 상태 기반 531라인), `sfr-user-guide.md` 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신, stale 3건(`data-sharing-analysis.md` / `next-refactoring.md` / `page-workflow.md`) 제거
## [2026-04-17] ## [2026-04-17]
### 변경 ### 변경

파일 보기

@ -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 })),

파일 보기

@ -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": "분류 저장에 실패했습니다"
}
} }
} }

파일 보기

@ -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로 특정 카탈로그 조회 */

파일 보기

@ -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',
];

파일 보기

@ -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,36 +414,26 @@ class FleetTracker:
match_method, confidence, now, now), match_method, confidence, now, now),
) )
else: else:
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인 # 새 MMSI — 이름이 공존 케이스면 다른 MMSI 활성행을 건드리지 않고 이번 것만 INSERT
cur.execute( if not is_colliding:
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG} # 교체 경로 — 같은 이름이 다른 MMSI 로 active 면 MMSI 교체로 간주
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
(name, mmsi),
)
old_mmsi_row = cur.fetchone()
if old_mmsi_row:
# 같은 이름 + 다른 MMSI → MMSI 변경
cur.execute( cur.execute(
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s', f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
(old_mmsi_row[0],), WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
(name, mmsi),
) )
logger.info('gear MMSI change: %s%s (name=%s)', old_mmsi_row[1], mmsi, name) old_mmsi_row = cur.fetchone()
if old_mmsi_row:
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
try:
cur.execute( cur.execute(
f"UPDATE {GEAR_CORRELATION_SCORES} " f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
"SET target_mmsi = %s, updated_at = NOW() " (old_mmsi_row[0],),
"WHERE target_mmsi = %s",
(mmsi, old_mmsi_row[1]),
) )
if cur.rowcount > 0: logger.info(
logger.info( 'gear MMSI change: %s%s (name=%s)',
'transferred %d affinity scores: %s%s', old_mmsi_row[1], mmsi, name,
cur.rowcount, old_mmsi_row[1], mmsi, )
) # 어피니티 점수 이전 (savepoint 로 격리 — PK 충돌 시 트랜잭션 유지)
except Exception as e: self._transfer_affinity_scores(cur, old_mmsi_row[1], mmsi)
logger.warning('affinity score transfer failed: %s', e)
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)