Merge pull request 'feat: 워크플로우 연결 + 메뉴 DB SSOT 구조화' (#26) from feature/workflow-connection-step1 into develop

This commit is contained in:
htlee 2026-04-09 16:03:25 +09:00
커밋 93429f012f
51개의 변경된 파일2775개의 추가작업 그리고 542개의 파일을 삭제

파일 보기

@ -4,6 +4,7 @@ import gc.mda.kcg.auth.dto.LoginRequest;
import gc.mda.kcg.auth.dto.UserInfoResponse; import gc.mda.kcg.auth.dto.UserInfoResponse;
import gc.mda.kcg.auth.provider.AuthProvider; import gc.mda.kcg.auth.provider.AuthProvider;
import gc.mda.kcg.config.AppProperties; import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.menu.MenuConfigService;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@ -25,6 +26,7 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final JwtService jwtService; private final JwtService jwtService;
private final AppProperties appProperties; private final AppProperties appProperties;
private final MenuConfigService menuConfigService;
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req, public ResponseEntity<?> login(@RequestBody LoginRequest req,
@ -95,7 +97,8 @@ public class AuthController {
u.getUserSttsCd(), u.getUserSttsCd(),
u.getAuthProvider(), u.getAuthProvider(),
info.roles(), info.roles(),
info.permissions() info.permissions(),
menuConfigService.getActiveMenuConfig()
); );
} }

파일 보기

@ -1,5 +1,7 @@
package gc.mda.kcg.auth.dto; package gc.mda.kcg.auth.dto;
import gc.mda.kcg.menu.MenuConfigDto;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -12,5 +14,6 @@ public record UserInfoResponse(
String status, String status,
String authProvider, String authProvider,
List<String> roles, List<String> roles,
Map<String, List<String>> permissions Map<String, List<String>> permissions,
List<MenuConfigDto> menuConfig
) {} ) {}

파일 보기

@ -0,0 +1,99 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
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.*;
import java.time.OffsetDateTime;
import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")
@RequiredArgsConstructor
public class VesselAnalysisController {
private final VesselAnalysisService service;
/**
* 분석 결과 목록 조회 (필터 + 페이징).
* 기본: 최근 1시간 결과.
*/
@GetMapping("/vessels")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listVessels(
@RequestParam(required = false) String mmsi,
@RequestParam(required = false) String zoneCode,
@RequestParam(required = false) String riskLevel,
@RequestParam(required = false) Boolean isDark,
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return service.getAnalysisResults(
mmsi, zoneCode, riskLevel, isDark, after,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(VesselAnalysisResponse::from);
}
/**
* 특정 선박 최신 분석 결과 (features 포함).
*/
@GetMapping("/vessels/{mmsi}")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public VesselAnalysisResponse getLatest(@PathVariable String mmsi) {
return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi));
}
/**
* 특정 선박 분석 이력 (기본 24시간).
*/
@GetMapping("/vessels/{mmsi}/history")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public List<VesselAnalysisResponse> getHistory(
@PathVariable String mmsi,
@RequestParam(defaultValue = "24") int hours
) {
return service.getHistory(mmsi, hours).stream()
.map(VesselAnalysisResponse::from)
.toList();
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/dark")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listDarkVessels(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getDarkVessels(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/transship")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listTransshipSuspects(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getTransshipSuspects(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
}

파일 보기

@ -29,7 +29,7 @@ public class VesselAnalysisProxyController {
private final ParentResolutionRepository resolutionRepository; private final ParentResolutionRepository resolutionRepository;
@GetMapping @GetMapping
@RequirePermission(resource = "detection", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() { public ResponseEntity<?> getVesselAnalysis() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis"); Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
if (data == null) { if (data == null) {

파일 보기

@ -0,0 +1,60 @@
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 org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
/**
* vessel_analysis_results 읽기 전용 Repository.
*/
public interface VesselAnalysisRepository
extends JpaRepository<VesselAnalysisResult, Long>, JpaSpecificationExecutor<VesselAnalysisResult> {
/**
* 특정 선박의 최신 분석 결과.
*/
Optional<VesselAnalysisResult> findTopByMmsiOrderByAnalyzedAtDesc(String mmsi);
/**
* 특정 선박의 분석 이력 (시간 범위).
*/
List<VesselAnalysisResult> findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(
String mmsi, OffsetDateTime after);
/**
* 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.isDark = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> findLatestDarkVessels(
@Param("after") OffsetDateTime after, Pageable pageable);
/**
* 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.transshipSuspect = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> findLatestTransshipSuspects(
@Param("after") OffsetDateTime after, Pageable pageable);
}

파일 보기

@ -0,0 +1,84 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
/**
* vessel_analysis_results 응답 DTO.
* 프론트엔드에서 필요한 핵심 필드만 포함.
*/
public record VesselAnalysisResponse(
Long id,
String mmsi,
OffsetDateTime analyzedAt,
// 분류
String vesselType,
BigDecimal confidence,
BigDecimal fishingPct,
String season,
// 위치
Double lat,
Double lon,
String zoneCode,
BigDecimal distToBaselineNm,
// 행동
String activityState,
// 위협
Boolean isDark,
Integer gapDurationMin,
String darkPattern,
BigDecimal spoofingScore,
Integer speedJumpCount,
// 환적
Boolean transshipSuspect,
String transshipPairMmsi,
Integer transshipDurationMin,
// 선단
Integer fleetClusterId,
String fleetRole,
Boolean fleetIsLeader,
// 위험도
Integer riskScore,
String riskLevel,
// 확장
String gearCode,
String gearJudgment,
String permitStatus,
// features
Map<String, Object> features
) {
public static VesselAnalysisResponse from(VesselAnalysisResult e) {
return new VesselAnalysisResponse(
e.getId(),
e.getMmsi(),
e.getAnalyzedAt(),
e.getVesselType(),
e.getConfidence(),
e.getFishingPct(),
e.getSeason(),
e.getLat(),
e.getLon(),
e.getZoneCode(),
e.getDistToBaselineNm(),
e.getActivityState(),
e.getIsDark(),
e.getGapDurationMin(),
e.getDarkPattern(),
e.getSpoofingScore(),
e.getSpeedJumpCount(),
e.getTransshipSuspect(),
e.getTransshipPairMmsi(),
e.getTransshipDurationMin(),
e.getFleetClusterId(),
e.getFleetRole(),
e.getFleetIsLeader(),
e.getRiskScore(),
e.getRiskLevel(),
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getFeatures()
);
}
}

파일 보기

@ -0,0 +1,135 @@
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.Map;
/**
* vessel_analysis_results 읽기 전용 Entity.
* prediction 엔진이 5분 주기로 INSERT, 백엔드는 READ만 수행.
*
* DB PK는 (id, analyzed_at) 복합키(파티션)이지만,
* BIGSERIAL id가 전역 유니크이므로 JPA에서는 id만 @Id로 매핑.
*/
@Entity
@Table(name = "vessel_analysis_results", schema = "kcg")
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class VesselAnalysisResult {
@Id
private Long id;
@Column(name = "mmsi", nullable = false, length = 20)
private String mmsi;
@Column(name = "analyzed_at", nullable = false)
private OffsetDateTime analyzedAt;
// 분류
@Column(name = "vessel_type", length = 30)
private String vesselType;
@Column(name = "confidence", precision = 5, scale = 4)
private BigDecimal confidence;
@Column(name = "fishing_pct", precision = 5, scale = 4)
private BigDecimal fishingPct;
@Column(name = "cluster_id")
private Integer clusterId;
@Column(name = "season", length = 20)
private String season;
// 위치
@Column(name = "lat")
private Double lat;
@Column(name = "lon")
private Double lon;
@Column(name = "zone_code", length = 30)
private String zoneCode;
@Column(name = "dist_to_baseline_nm", precision = 8, scale = 2)
private BigDecimal distToBaselineNm;
// 행동 분석
@Column(name = "activity_state", length = 20)
private String activityState;
@Column(name = "ucaf_score", precision = 5, scale = 4)
private BigDecimal ucafScore;
@Column(name = "ucft_score", precision = 5, scale = 4)
private BigDecimal ucftScore;
// 위협 탐지
@Column(name = "is_dark")
private Boolean isDark;
@Column(name = "gap_duration_min")
private Integer gapDurationMin;
@Column(name = "dark_pattern", length = 30)
private String darkPattern;
@Column(name = "spoofing_score", precision = 5, scale = 4)
private BigDecimal spoofingScore;
@Column(name = "bd09_offset_m", precision = 8, scale = 2)
private BigDecimal bd09OffsetM;
@Column(name = "speed_jump_count")
private Integer speedJumpCount;
// 환적
@Column(name = "transship_suspect")
private Boolean transshipSuspect;
@Column(name = "transship_pair_mmsi", length = 20)
private String transshipPairMmsi;
@Column(name = "transship_duration_min")
private Integer transshipDurationMin;
// 선단
@Column(name = "fleet_cluster_id")
private Integer fleetClusterId;
@Column(name = "fleet_role", length = 20)
private String fleetRole;
@Column(name = "fleet_is_leader")
private Boolean fleetIsLeader;
// 위험도
@Column(name = "risk_score")
private Integer riskScore;
@Column(name = "risk_level", length = 20)
private String riskLevel;
// 확장
@Column(name = "gear_code", length = 20)
private String gearCode;
@Column(name = "gear_judgment", length = 30)
private String gearJudgment;
@Column(name = "permit_status", length = 20)
private String permitStatus;
// features JSONB
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "created_at")
private OffsetDateTime createdAt;
}

파일 보기

@ -0,0 +1,83 @@
package gc.mda.kcg.domain.analysis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
/**
* vessel_analysis_results 직접 조회 서비스.
* prediction이 write한 분석 결과를 프론트엔드에 제공.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VesselAnalysisService {
private final VesselAnalysisRepository repository;
/**
* 분석 결과 목록 조회 (동적 필터).
*/
public Page<VesselAnalysisResult> getAnalysisResults(
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
OffsetDateTime after, Pageable pageable
) {
Specification<VesselAnalysisResult> spec = Specification.where(null);
if (after != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
}
if (mmsi != null && !mmsi.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
}
if (zoneCode != null && !zoneCode.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
}
if (riskLevel != null && !riskLevel.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel));
}
if (isDark != null && isDark) {
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
}
return repository.findAll(spec, pageable);
}
/**
* 특정 선박 최신 분석 결과.
*/
public VesselAnalysisResult getLatestByMmsi(String mmsi) {
return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi)
.orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi));
}
/**
* 특정 선박 분석 이력 (시간 범위).
*/
public List<VesselAnalysisResult> getHistory(String mmsi, int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after);
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getDarkVessels(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestDarkVessels(after, pageable);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getTransshipSuspects(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestTransshipSuspects(after, pageable);
}
}

파일 보기

@ -32,9 +32,10 @@ public class EnforcementController {
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ") @RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
public Page<EnforcementRecord> listRecords( public Page<EnforcementRecord> listRecords(
@RequestParam(required = false) String violationType, @RequestParam(required = false) String violationType,
@RequestParam(required = false) String vesselMmsi,
Pageable pageable Pageable pageable
) { ) {
return service.listRecords(violationType, pageable); return service.listRecords(violationType, vesselMmsi, pageable);
} }
/** /**

파일 보기

@ -32,7 +32,10 @@ public class EnforcementService {
// 단속 이력 // 단속 이력
// ======================================================================== // ========================================================================
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) { public Page<EnforcementRecord> listRecords(String violationType, String vesselMmsi, Pageable pageable) {
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable);
}
if (violationType != null && !violationType.isBlank()) { if (violationType != null && !violationType.isBlank()) {
return recordRepository.findByViolationType(violationType, pageable); return recordRepository.findByViolationType(violationType, pageable);
} }

파일 보기

@ -8,4 +8,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> { public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable); Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable); Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable);
Page<EnforcementRecord> findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable);
} }

파일 보기

@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
@ -93,6 +94,10 @@ public class PredictionEvent {
@Column(name = "dedup_key", length = 200) @Column(name = "dedup_key", length = 200)
private String dedupKey; private String dedupKey;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;

파일 보기

@ -27,7 +27,7 @@ public class StatsController {
* 실시간 KPI 전체 목록 조회 * 실시간 KPI 전체 목록 조회
*/ */
@GetMapping("/kpi") @GetMapping("/kpi")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionKpi> getKpi() { public List<PredictionKpi> getKpi() {
return kpiRepository.findAll(); return kpiRepository.findAll();
} }
@ -38,7 +38,7 @@ public class StatsController {
* @param to 종료 (: 2026-04) * @param to 종료 (: 2026-04)
*/ */
@GetMapping("/monthly") @GetMapping("/monthly")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsMonthly> getMonthly( public List<PredictionStatsMonthly> getMonthly(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -52,7 +52,7 @@ public class StatsController {
* @param to 종료 날짜 (: 2026-04-07) * @param to 종료 날짜 (: 2026-04-07)
*/ */
@GetMapping("/daily") @GetMapping("/daily")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsDaily> getDaily( public List<PredictionStatsDaily> getDaily(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -65,7 +65,7 @@ public class StatsController {
* @param hours 조회 시간 범위 (기본 24시간) * @param hours 조회 시간 범위 (기본 24시간)
*/ */
@GetMapping("/hourly") @GetMapping("/hourly")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsHourly> getHourly( public List<PredictionStatsHourly> getHourly(
@RequestParam(defaultValue = "24") int hours @RequestParam(defaultValue = "24") int hours
) { ) {

파일 보기

@ -79,13 +79,13 @@ public class MasterDataController {
// ======================================================================== // ========================================================================
@GetMapping("/api/patrol-ships") @GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol", operation = "READ") @RequirePermission(resource = "patrol:patrol-route", operation = "READ")
public List<PatrolShip> listPatrolShips() { public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode(); return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
} }
@PatchMapping("/api/patrol-ships/{id}/status") @PatchMapping("/api/patrol-ships/{id}/status")
@RequirePermission(resource = "patrol", operation = "UPDATE") @RequirePermission(resource = "patrol:patrol-route", operation = "UPDATE")
public PatrolShip updatePatrolShipStatus( public PatrolShip updatePatrolShipStatus(
@PathVariable Long id, @PathVariable Long id,
@RequestBody PatrolShipStatusRequest request @RequestBody PatrolShipStatusRequest request
@ -108,7 +108,7 @@ public class MasterDataController {
// ======================================================================== // ========================================================================
@GetMapping("/api/vessel-permits") @GetMapping("/api/vessel-permits")
@RequirePermission(resource = "vessel", operation = "READ") // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public Page<VesselPermit> listVesselPermits( public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag, @RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus, @RequestParam(required = false) String permitStatus,
@ -126,7 +126,7 @@ public class MasterDataController {
} }
@GetMapping("/api/vessel-permits/{mmsi}") @GetMapping("/api/vessel-permits/{mmsi}")
@RequirePermission(resource = "vessel", operation = "READ") // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public VesselPermit getVesselPermit(@PathVariable String mmsi) { public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi) return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,

파일 보기

@ -0,0 +1,21 @@
package gc.mda.kcg.menu;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 메뉴 설정 API auth_perm_tree 기반.
*/
@RestController
@RequiredArgsConstructor
public class MenuConfigController {
private final MenuConfigService menuConfigService;
@GetMapping("/api/menu-config")
public List<MenuConfigDto> getMenuConfig() {
return menuConfigService.getActiveMenuConfig();
}
}

파일 보기

@ -0,0 +1,19 @@
package gc.mda.kcg.menu;
import java.util.Map;
public record MenuConfigDto(
String menuCd,
String parentMenuCd,
String menuType,
String urlPath,
String rsrcCd,
String componentKey,
String icon,
String labelKey,
String dividerLabel,
int menuLevel,
int sortOrd,
String useYn,
Map<String, String> labels
) {}

파일 보기

@ -0,0 +1,115 @@
package gc.mda.kcg.menu;
import gc.mda.kcg.permission.PermTree;
import gc.mda.kcg.permission.PermTreeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* auth_perm_tree를 메뉴 SSOT로 사용하여 MenuConfigDto 목록을 생성.
* 디바이더는 nav_sub_group 변경 시점에 동적 삽입.
*/
@Service
@RequiredArgsConstructor
public class MenuConfigService {
private final PermTreeRepository permTreeRepository;
// 메뉴 그룹으로 동작하는 Level-0 노드 rsrc_cd
private static final Set<String> GROUP_NODES = Set.of(
"field-ops", "parent-inference-workflow", "admin"
);
@Cacheable("menuConfig")
@Transactional(readOnly = true)
public List<MenuConfigDto> getActiveMenuConfig() {
List<PermTree> all = permTreeRepository.findByUseYn("Y");
List<MenuConfigDto> result = new ArrayList<>();
// 1) 최상위 ITEM (nav_sort > 0, nav_group IS NULL, url_path IS NOT NULL)
List<PermTree> topItems = all.stream()
.filter(n -> n.getNavSort() > 0 && n.getNavGroup() == null && n.getUrlPath() != null)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
for (PermTree n : topItems) {
result.add(toDto(n, null, "ITEM", 0));
}
// 2) 그룹 헤더 + 자식 (nav_sort > 0 GROUP_NODES)
List<PermTree> groups = all.stream()
.filter(n -> GROUP_NODES.contains(n.getRsrcCd()) && n.getNavSort() > 0)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
for (PermTree g : groups) {
result.add(toDto(g, null, "GROUP", 0));
// 그룹의 자식들
List<PermTree> children = all.stream()
.filter(n -> g.getRsrcCd().equals(n.getNavGroup()) && n.getUrlPath() != null)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
// 디바이더 삽입: nav_sub_group 변경 시점마다
String currentSubGroup = null;
int dividerSeq = 0;
for (PermTree c : children) {
String sub = c.getNavSubGroup();
if (sub != null && !sub.equals(currentSubGroup)) {
currentSubGroup = sub;
dividerSeq++;
result.add(new MenuConfigDto(
g.getRsrcCd() + ".div-" + dividerSeq,
g.getRsrcCd(), "DIVIDER",
null, null, null, null, null,
sub, 1, c.getNavSort() - 1, "Y",
Map.of()
));
}
result.add(toDto(c, g.getRsrcCd(), "ITEM", 1));
}
}
// 3) 숨김 라우트 (nav_sort = 0, url_path IS NOT NULL)
List<PermTree> hidden = all.stream()
.filter(n -> n.getNavSort() == 0 && n.getUrlPath() != null && !GROUP_NODES.contains(n.getRsrcCd()))
.toList();
for (PermTree h : hidden) {
result.add(new MenuConfigDto(
h.getRsrcCd(), null, "ITEM",
h.getUrlPath(), h.getRsrcCd(), h.getComponentKey(),
h.getIcon(), h.getLabelKey(), null,
0, 9999, "H",
h.getLabels() != null ? h.getLabels() : Map.of()
));
}
return result;
}
@CacheEvict(value = "menuConfig", allEntries = true)
public void evictCache() {
}
private MenuConfigDto toDto(PermTree n, String parentMenuCd, String menuType, int menuLevel) {
return new MenuConfigDto(
n.getRsrcCd(),
parentMenuCd,
menuType,
n.getUrlPath(),
n.getRsrcCd(),
n.getComponentKey(),
n.getIcon(),
n.getLabelKey(),
n.getNavSubGroup(),
menuLevel,
n.getNavSort(),
n.getUseYn(),
n.getLabels() != null ? n.getLabels() : Map.of()
);
}
}

파일 보기

@ -2,6 +2,8 @@ package gc.mda.kcg.permission;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@ -39,6 +41,29 @@ public class PermTree {
@Column(name = "use_yn", nullable = false, length = 1) @Column(name = "use_yn", nullable = false, length = 1)
private String useYn; private String useYn;
// 메뉴 SSOT 컬럼 (V021)
@Column(name = "url_path", length = 200)
private String urlPath;
@Column(name = "label_key", length = 100)
private String labelKey;
@Column(name = "component_key", length = 150)
private String componentKey;
@Column(name = "nav_group", length = 100)
private String navGroup;
@Column(name = "nav_sub_group", length = 100)
private String navSubGroup;
@Column(name = "nav_sort", nullable = false)
private Integer navSort;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "labels", columnDefinition = "jsonb")
private java.util.Map<String, String> labels;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@ -53,6 +78,7 @@ public class PermTree {
if (useYn == null) useYn = "Y"; if (useYn == null) useYn = "Y";
if (sortOrd == null) sortOrd = 0; if (sortOrd == null) sortOrd = 0;
if (rsrcLevel == null) rsrcLevel = 0; if (rsrcLevel == null) rsrcLevel = 0;
if (navSort == null) navSort = 0;
} }
@PreUpdate @PreUpdate

파일 보기

@ -33,7 +33,7 @@ spring:
cache: cache:
type: caffeine type: caffeine
cache-names: permissions,users cache-names: permissions,users,menuConfig
caffeine: caffeine:
spec: maximumSize=1000,expireAfterWrite=10m spec: maximumSize=1000,expireAfterWrite=10m

파일 보기

@ -0,0 +1,11 @@
-- ============================================================
-- V018: prediction_events에 features JSONB 컬럼 추가
-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을
-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다.
-- ============================================================
ALTER TABLE kcg.prediction_events
ADD COLUMN IF NOT EXISTS features JSONB;
COMMENT ON COLUMN kcg.prediction_events.features IS
'분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)';

파일 보기

@ -0,0 +1,16 @@
-- ============================================================
-- V019: LLM 운영 페이지 권한 트리 항목 추가
-- PR #22에서 추가된 /llm-ops 페이지에 대응하는 권한 리소스
-- ============================================================
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('ai-operations:llm-ops', 'ai-operations', 'LLM 운영', 1, 35)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ADMIN 역할에 자동 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:llm-ops', 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;

파일 보기

@ -0,0 +1,97 @@
-- ============================================================================
-- V020: 메뉴 설정 SSOT 테이블
-- 프론트엔드 사이드바 + 라우팅 + 권한 매핑의 단일 진실 공급원
-- ============================================================================
CREATE TABLE kcg.menu_config (
menu_cd VARCHAR(100) PRIMARY KEY,
parent_menu_cd VARCHAR(100) REFERENCES kcg.menu_config(menu_cd) ON DELETE CASCADE,
menu_type VARCHAR(10) NOT NULL DEFAULT 'ITEM',
url_path VARCHAR(200),
rsrc_cd VARCHAR(100) REFERENCES kcg.auth_perm_tree(rsrc_cd),
component_key VARCHAR(150),
icon VARCHAR(50),
label_key VARCHAR(100),
divider_label VARCHAR(100),
menu_level INT NOT NULL DEFAULT 0,
sort_ord INT NOT NULL DEFAULT 0,
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE kcg.menu_config IS '메뉴 레이아웃 SSOT — 사이드바, 라우팅, 경로→권한 매핑 구동';
COMMENT ON COLUMN kcg.menu_config.menu_type IS 'ITEM(네비게이션) | GROUP(접기/펼치기) | DIVIDER(서브그룹 라벨)';
COMMENT ON COLUMN kcg.menu_config.rsrc_cd IS 'auth_perm_tree FK. 권한 체크용. GROUP/DIVIDER는 NULL';
COMMENT ON COLUMN kcg.menu_config.component_key IS '프론트 COMPONENT_REGISTRY 키. GROUP/DIVIDER는 NULL';
COMMENT ON COLUMN kcg.menu_config.use_yn IS 'Y=사이드바+라우트, H=라우트만(숨김), N=비활성';
CREATE INDEX idx_menu_config_parent ON kcg.menu_config(parent_menu_cd);
CREATE INDEX idx_menu_config_sort ON kcg.menu_config(menu_level, sort_ord);
CREATE INDEX idx_menu_config_rsrc ON kcg.menu_config(rsrc_cd);
-- ============================================================================
-- 시드 데이터: 현재 NAV_ENTRIES + Route 정의 기반 35건
-- sort_ord 100 간격 (삽입 여유)
-- ============================================================================
-- ─── Top-level ITEM (13개) ─────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('dashboard', NULL, 'ITEM', '/dashboard', 'dashboard', 'features/dashboard/Dashboard', 'LayoutDashboard', 'nav.dashboard', 0, 100),
('monitoring', NULL, 'ITEM', '/monitoring', 'monitoring', 'features/monitoring/MonitoringDashboard', 'Activity', 'nav.monitoring', 0, 200),
('events', NULL, 'ITEM', '/events', 'surveillance:live-map', 'features/surveillance/LiveMapView', 'Radar', 'nav.realtimeEvent', 0, 300),
('map-control', NULL, 'ITEM', '/map-control', 'surveillance:map-control', 'features/surveillance/MapControl', 'Map', 'nav.mapControl', 0, 400),
('risk-map', NULL, 'ITEM', '/risk-map', 'risk-assessment:risk-map', 'features/risk-assessment/RiskMap', 'Layers', 'nav.riskMap', 0, 500),
('enforcement-plan', NULL, 'ITEM', '/enforcement-plan', 'risk-assessment:enforcement-plan', 'features/risk-assessment/EnforcementPlan', 'Shield', 'nav.enforcementPlan', 0, 600),
('dark-vessel', NULL, 'ITEM', '/dark-vessel', 'detection:dark-vessel', 'features/detection/DarkVesselDetection', 'EyeOff', 'nav.darkVessel', 0, 700),
('gear-detection', NULL, 'ITEM', '/gear-detection', 'detection:gear-detection', 'features/detection/GearDetection', 'Anchor', 'nav.gearDetection', 0, 800),
('china-fishing', NULL, 'ITEM', '/china-fishing', 'detection:china-fishing', 'features/detection/ChinaFishing', 'Ship', 'nav.chinaFishing', 0, 900),
('enforcement-history', NULL, 'ITEM', '/enforcement-history', 'enforcement:enforcement-history', 'features/enforcement/EnforcementHistory', 'FileText', 'nav.enforcementHistory', 0, 1000),
('event-list', NULL, 'ITEM', '/event-list', 'enforcement:event-list', 'features/enforcement/EventList', 'List', 'nav.eventList', 0, 1100),
('statistics', NULL, 'ITEM', '/statistics', 'statistics:statistics', 'features/statistics/Statistics', 'BarChart3', 'nav.statistics', 0, 1200),
('reports', NULL, 'ITEM', '/reports', 'statistics:statistics', 'features/statistics/ReportManagement', 'FileText', 'nav.reports', 0, 1300);
-- ─── GROUP: 현장작전 ───────────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('field-ops', NULL, 'GROUP', NULL, NULL, NULL, 'Ship', 'group.fieldOps', 0, 1400),
('field-ops.patrol', 'field-ops', 'ITEM', '/patrol-route', 'patrol:patrol-route', 'features/patrol/PatrolRoute', 'Navigation', 'nav.patrolRoute', 1, 100),
('field-ops.fleet', 'field-ops', 'ITEM', '/fleet-optimization', 'patrol:fleet-optimization', 'features/patrol/FleetOptimization', 'Users', 'nav.fleetOptimization', 1, 200),
('field-ops.alert', 'field-ops', 'ITEM', '/ai-alert', 'field-ops:ai-alert', 'features/field-ops/AIAlert', 'Send', 'nav.aiAlert', 1, 300),
('field-ops.mobile', 'field-ops', 'ITEM', '/mobile-service', 'field-ops:mobile-service', 'features/field-ops/MobileService', 'Smartphone', 'nav.mobileService', 1, 400),
('field-ops.ship', 'field-ops', 'ITEM', '/ship-agent', 'field-ops:ship-agent', 'features/field-ops/ShipAgent', 'Monitor', 'nav.shipAgent', 1, 500);
-- ─── GROUP: 모선 워크플로우 ────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('parent-inference', NULL, 'GROUP', NULL, NULL, NULL, 'GitBranch', 'group.parentInference', 0, 1500),
('parent-inference.review', 'parent-inference', 'ITEM', '/parent-inference/review', 'parent-inference-workflow:parent-review', 'features/parent-inference/ParentReview', 'CheckSquare', 'nav.parentReview', 1, 100),
('parent-inference.exclusion', 'parent-inference', 'ITEM', '/parent-inference/exclusion', 'parent-inference-workflow:parent-exclusion', 'features/parent-inference/ParentExclusion', 'Ban', 'nav.parentExclusion', 1, 200),
('parent-inference.label', 'parent-inference', 'ITEM', '/parent-inference/label-session', 'parent-inference-workflow:label-session', 'features/parent-inference/LabelSession', 'Tag', 'nav.labelSession', 1, 300);
-- ─── GROUP: 관리자 ─────────────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, divider_label, menu_level, sort_ord) VALUES
('admin-group', NULL, 'GROUP', NULL, NULL, NULL, 'Settings', 'group.admin', NULL, 0, 1600),
-- AI 플랫폼
('admin-group.div-ai', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, 'AI 플랫폼', 1, 100),
('admin-group.ai-model', 'admin-group', 'ITEM', '/ai-model', 'ai-operations:ai-model', 'features/ai-operations/AIModelManagement', 'Brain', 'nav.aiModel', NULL, 1, 200),
('admin-group.mlops', 'admin-group', 'ITEM', '/mlops', 'ai-operations:mlops', 'features/ai-operations/MLOpsPage', 'Cpu', 'nav.mlops', NULL, 1, 300),
('admin-group.llm-ops', 'admin-group', 'ITEM', '/llm-ops', 'ai-operations:llm-ops', 'features/ai-operations/LLMOpsPage', 'Brain', 'nav.llmOps', NULL, 1, 400),
('admin-group.ai-assistant', 'admin-group', 'ITEM', '/ai-assistant', 'ai-operations:ai-assistant', 'features/ai-operations/AIAssistant', 'MessageSquare', 'nav.aiAssistant', NULL, 1, 500),
-- 시스템 운영
('admin-group.div-sys', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '시스템 운영', 1, 600),
('admin-group.system-config', 'admin-group', 'ITEM', '/system-config', 'admin:system-config', 'features/admin/SystemConfig', 'Database', 'nav.systemConfig',NULL, 1, 700),
('admin-group.data-hub', 'admin-group', 'ITEM', '/data-hub', 'admin:system-config', 'features/admin/DataHub', 'Wifi', 'nav.dataHub', NULL, 1, 800),
('admin-group.external', 'admin-group', 'ITEM', '/external-service', 'statistics:external-service', 'features/statistics/ExternalService', 'Globe', 'nav.externalService', NULL, 1, 900),
-- 사용자 관리
('admin-group.div-user', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '사용자 관리', 1, 1000),
('admin-group.admin', 'admin-group', 'ITEM', '/admin', 'admin', 'features/admin/AdminPanel', 'Settings', 'nav.admin', NULL, 1, 1100),
('admin-group.access', 'admin-group', 'ITEM', '/access-control', 'admin:permission-management', 'features/admin/AccessControl', 'Fingerprint', 'nav.accessControl', NULL, 1, 1200),
('admin-group.notices', 'admin-group', 'ITEM', '/notices', 'admin', 'features/admin/NoticeManagement', 'Megaphone', 'nav.notices', NULL, 1, 1300),
-- 감사·보안
('admin-group.div-audit', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '감사·보안', 1, 1400),
('admin-group.audit-logs', 'admin-group', 'ITEM', '/admin/audit-logs', 'admin:audit-logs', 'features/admin/AuditLogs', 'ScrollText', 'nav.auditLogs', NULL, 1, 1500),
('admin-group.access-logs', 'admin-group', 'ITEM', '/admin/access-logs', 'admin:access-logs', 'features/admin/AccessLogs', 'History', 'nav.accessLogs', NULL, 1, 1600),
('admin-group.login-history', 'admin-group', 'ITEM', '/admin/login-history', 'admin:login-history', 'features/admin/LoginHistoryView', 'KeyRound', 'nav.loginHistory',NULL, 1, 1700);
-- ─── 숨김 라우트 (사이드바 미표시, 라우팅만) ───────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord, use_yn) VALUES
('vessel-detail', NULL, 'ITEM', '/vessel/:id', 'vessel:vessel-detail', 'features/vessel/VesselDetail', NULL, NULL, 0, 9999, 'H');

파일 보기

@ -0,0 +1,115 @@
-- ============================================================================
-- V021: auth_perm_tree를 메뉴 SSOT로 확장 + menu_config 테이블 폐기
-- 메뉴·권한·감사가 동일 레코드를 참조하여 완전 동기화
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. auth_perm_tree에 메뉴 컬럼 추가
-- ──────────────────────────────────────────────────────────────────
ALTER TABLE kcg.auth_perm_tree ADD COLUMN url_path VARCHAR(200);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN label_key VARCHAR(100);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN component_key VARCHAR(150);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_group VARCHAR(100); -- 소속 메뉴 그룹 (NULL=최상위)
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sub_group VARCHAR(100); -- 디바이더 라벨 (admin 서브그룹)
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sort INT NOT NULL DEFAULT 0; -- 메뉴 정렬 (0=미표시)
-- ──────────────────────────────────────────────────────────────────
-- 2. 공유 리소스 분리 — 1메뉴=1노드 보장
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('statistics:reports', 'statistics', '보고서 관리', 1, 30)
ON CONFLICT (rsrc_cd) DO NOTHING;
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-hub', 'admin', '데이터 허브', 1, 85)
ON CONFLICT (rsrc_cd) DO NOTHING;
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:notices', 'admin', '공지사항', 1, 45)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 신규 노드에 ADMIN 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, n.rsrc_cd, op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('statistics:reports'), ('admin:data-hub'), ('admin:notices')) AS n(rsrc_cd)
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;
-- 기존 역할에도 READ 부여 (statistics:reports → VIEWER 이상, admin:* → ADMIN 전용)
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'statistics:reports', 'READ', 'Y'
FROM kcg.auth_role r WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'OPERATOR', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 메뉴 데이터 채우기 — 최상위 ITEM (13개)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/dashboard', label_key='nav.dashboard', component_key='features/dashboard/Dashboard', nav_sort=100 WHERE rsrc_cd='dashboard';
UPDATE kcg.auth_perm_tree SET url_path='/monitoring', label_key='nav.monitoring', component_key='features/monitoring/MonitoringDashboard', nav_sort=200 WHERE rsrc_cd='monitoring';
UPDATE kcg.auth_perm_tree SET url_path='/events', label_key='nav.realtimeEvent', component_key='features/surveillance/LiveMapView', nav_sort=300 WHERE rsrc_cd='surveillance:live-map';
UPDATE kcg.auth_perm_tree SET url_path='/map-control', label_key='nav.mapControl', component_key='features/surveillance/MapControl', nav_sort=400 WHERE rsrc_cd='surveillance:map-control';
UPDATE kcg.auth_perm_tree SET url_path='/risk-map', label_key='nav.riskMap', component_key='features/risk-assessment/RiskMap', nav_sort=500 WHERE rsrc_cd='risk-assessment:risk-map';
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-plan', label_key='nav.enforcementPlan', component_key='features/risk-assessment/EnforcementPlan', nav_sort=600 WHERE rsrc_cd='risk-assessment:enforcement-plan';
UPDATE kcg.auth_perm_tree SET url_path='/dark-vessel', label_key='nav.darkVessel', component_key='features/detection/DarkVesselDetection', nav_sort=700 WHERE rsrc_cd='detection:dark-vessel';
UPDATE kcg.auth_perm_tree SET url_path='/gear-detection', label_key='nav.gearDetection', component_key='features/detection/GearDetection', nav_sort=800 WHERE rsrc_cd='detection:gear-detection';
UPDATE kcg.auth_perm_tree SET url_path='/china-fishing', label_key='nav.chinaFishing', component_key='features/detection/ChinaFishing', nav_sort=900 WHERE rsrc_cd='detection:china-fishing';
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-history',label_key='nav.enforcementHistory', component_key='features/enforcement/EnforcementHistory', nav_sort=1000 WHERE rsrc_cd='enforcement:enforcement-history';
UPDATE kcg.auth_perm_tree SET url_path='/event-list', label_key='nav.eventList', component_key='features/enforcement/EventList', nav_sort=1100 WHERE rsrc_cd='enforcement:event-list';
UPDATE kcg.auth_perm_tree SET url_path='/statistics', label_key='nav.statistics', component_key='features/statistics/Statistics', nav_sort=1200 WHERE rsrc_cd='statistics:statistics';
UPDATE kcg.auth_perm_tree SET url_path='/reports', label_key='nav.reports', component_key='features/statistics/ReportManagement', nav_sort=1300 WHERE rsrc_cd='statistics:reports';
-- ──────────────────────────────────────────────────────────────────
-- 4. 그룹 헤더 (Level 0 노드에 label_key + nav_sort)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET label_key='group.fieldOps', nav_sort=1400 WHERE rsrc_cd='field-ops';
UPDATE kcg.auth_perm_tree SET label_key='group.parentInference', nav_sort=1500 WHERE rsrc_cd='parent-inference-workflow';
UPDATE kcg.auth_perm_tree SET label_key='group.admin', nav_sort=1600 WHERE rsrc_cd='admin';
-- ──────────────────────────────────────────────────────────────────
-- 5. 그룹 자식 — field-ops
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/patrol-route', label_key='nav.patrolRoute', component_key='features/patrol/PatrolRoute', nav_group='field-ops', nav_sort=100 WHERE rsrc_cd='patrol:patrol-route';
UPDATE kcg.auth_perm_tree SET url_path='/fleet-optimization', label_key='nav.fleetOptimization', component_key='features/patrol/FleetOptimization', nav_group='field-ops', nav_sort=200 WHERE rsrc_cd='patrol:fleet-optimization';
UPDATE kcg.auth_perm_tree SET url_path='/ai-alert', label_key='nav.aiAlert', component_key='features/field-ops/AIAlert', nav_group='field-ops', nav_sort=300 WHERE rsrc_cd='field-ops:ai-alert';
UPDATE kcg.auth_perm_tree SET url_path='/mobile-service', label_key='nav.mobileService', component_key='features/field-ops/MobileService', nav_group='field-ops', nav_sort=400 WHERE rsrc_cd='field-ops:mobile-service';
UPDATE kcg.auth_perm_tree SET url_path='/ship-agent', label_key='nav.shipAgent', component_key='features/field-ops/ShipAgent', nav_group='field-ops', nav_sort=500 WHERE rsrc_cd='field-ops:ship-agent';
-- ──────────────────────────────────────────────────────────────────
-- 6. 그룹 자식 — parent-inference
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/review', label_key='nav.parentReview', component_key='features/parent-inference/ParentReview', nav_group='parent-inference-workflow', nav_sort=100 WHERE rsrc_cd='parent-inference-workflow:parent-review';
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/exclusion', label_key='nav.parentExclusion', component_key='features/parent-inference/ParentExclusion', nav_group='parent-inference-workflow', nav_sort=200 WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/label-session', label_key='nav.labelSession', component_key='features/parent-inference/LabelSession', nav_group='parent-inference-workflow', nav_sort=300 WHERE rsrc_cd='parent-inference-workflow:label-session';
-- ──────────────────────────────────────────────────────────────────
-- 7. 그룹 자식 — admin (서브그룹 포함)
-- ──────────────────────────────────────────────────────────────────
-- AI 플랫폼
UPDATE kcg.auth_perm_tree SET url_path='/ai-model', label_key='nav.aiModel', component_key='features/ai-operations/AIModelManagement', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=200 WHERE rsrc_cd='ai-operations:ai-model';
UPDATE kcg.auth_perm_tree SET url_path='/mlops', label_key='nav.mlops', component_key='features/ai-operations/MLOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=300 WHERE rsrc_cd='ai-operations:mlops';
UPDATE kcg.auth_perm_tree SET url_path='/llm-ops', label_key='nav.llmOps', component_key='features/ai-operations/LLMOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=400 WHERE rsrc_cd='ai-operations:llm-ops';
UPDATE kcg.auth_perm_tree SET url_path='/ai-assistant', label_key='nav.aiAssistant', component_key='features/ai-operations/AIAssistant', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=500 WHERE rsrc_cd='ai-operations:ai-assistant';
-- 시스템 운영
UPDATE kcg.auth_perm_tree SET url_path='/system-config', label_key='nav.systemConfig', component_key='features/admin/SystemConfig', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=700 WHERE rsrc_cd='admin:system-config';
UPDATE kcg.auth_perm_tree SET url_path='/data-hub', label_key='nav.dataHub', component_key='features/admin/DataHub', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=800 WHERE rsrc_cd='admin:data-hub';
UPDATE kcg.auth_perm_tree SET url_path='/external-service',label_key='nav.externalService',component_key='features/statistics/ExternalService', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=900 WHERE rsrc_cd='statistics:external-service';
-- 사용자 관리
UPDATE kcg.auth_perm_tree SET url_path='/admin', label_key='nav.admin', component_key='features/admin/AdminPanel', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1100 WHERE rsrc_cd='admin:user-management';
UPDATE kcg.auth_perm_tree SET url_path='/access-control', label_key='nav.accessControl', component_key='features/admin/AccessControl', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1200 WHERE rsrc_cd='admin:permission-management';
UPDATE kcg.auth_perm_tree SET url_path='/notices', label_key='nav.notices', component_key='features/admin/NoticeManagement', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1300 WHERE rsrc_cd='admin:notices';
-- 감사·보안
UPDATE kcg.auth_perm_tree SET url_path='/admin/audit-logs', label_key='nav.auditLogs', component_key='features/admin/AuditLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1500 WHERE rsrc_cd='admin:audit-logs';
UPDATE kcg.auth_perm_tree SET url_path='/admin/access-logs', label_key='nav.accessLogs', component_key='features/admin/AccessLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1600 WHERE rsrc_cd='admin:access-logs';
UPDATE kcg.auth_perm_tree SET url_path='/admin/login-history',label_key='nav.loginHistory', component_key='features/admin/LoginHistoryView', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1700 WHERE rsrc_cd='admin:login-history';
-- ──────────────────────────────────────────────────────────────────
-- 8. 숨김 라우트 (라우팅만, 사이드바 미표시)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/vessel/:id', component_key='features/vessel/VesselDetail' WHERE rsrc_cd='vessel:vessel-detail';
-- ──────────────────────────────────────────────────────────────────
-- 9. menu_config 테이블 폐기
-- ──────────────────────────────────────────────────────────────────
DROP TABLE IF EXISTS kcg.menu_config;

파일 보기

@ -0,0 +1,83 @@
-- ============================================================================
-- V022: auth_perm_tree에 다국어 라벨 JSONB — DB가 i18n SSOT
-- labels = {"ko": "종합 상황판", "en": "Dashboard"} (언어 추가 시 DDL 변경 불필요)
-- ============================================================================
ALTER TABLE kcg.auth_perm_tree ADD COLUMN labels JSONB NOT NULL DEFAULT '{}';
-- ──────────────────────────────────────────────────────────────────
-- 최상위 ITEM
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"종합 상황판","en":"Dashboard"}' WHERE rsrc_cd='dashboard';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"경보 현황판","en":"Alert Monitor"}' WHERE rsrc_cd='monitoring';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"실시간 이벤트","en":"Realtime Events"}' WHERE rsrc_cd='surveillance:live-map';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"해역 관리","en":"Zone Management"}' WHERE rsrc_cd='surveillance:map-control';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험도 지도","en":"Risk Map"}' WHERE rsrc_cd='risk-assessment:risk-map';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 계획","en":"Enforcement Plan"}' WHERE rsrc_cd='risk-assessment:enforcement-plan';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다크베셀 탐지","en":"Dark Vessel Detection"}' WHERE rsrc_cd='detection:dark-vessel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구 탐지","en":"Gear Detection"}' WHERE rsrc_cd='detection:gear-detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"중국어선 분석","en":"China Fishing"}' WHERE rsrc_cd='detection:china-fishing';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 이력","en":"Enforcement History"}' WHERE rsrc_cd='enforcement:enforcement-history';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"이벤트 목록","en":"Event List"}' WHERE rsrc_cd='enforcement:event-list';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계 분석","en":"Statistics"}' WHERE rsrc_cd='statistics:statistics';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"보고서 관리","en":"Report Management"}' WHERE rsrc_cd='statistics:reports';
-- ──────────────────────────────────────────────────────────────────
-- 그룹 헤더
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"현장작전","en":"Field Operations"}' WHERE rsrc_cd='field-ops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 워크플로우","en":"Parent Inference"}' WHERE rsrc_cd='parent-inference-workflow';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Administration"}' WHERE rsrc_cd='admin';
-- ──────────────────────────────────────────────────────────────────
-- field-ops 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰경로 추천","en":"Patrol Route"}' WHERE rsrc_cd='patrol:patrol-route';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다함정 최적화","en":"Fleet Optimization"}' WHERE rsrc_cd='patrol:fleet-optimization';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 알림 발송","en":"AI Alert"}' WHERE rsrc_cd='field-ops:ai-alert';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모바일 서비스","en":"Mobile Service"}' WHERE rsrc_cd='field-ops:mobile-service';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"함정 Agent","en":"Ship Agent"}' WHERE rsrc_cd='field-ops:ship-agent';
-- ──────────────────────────────────────────────────────────────────
-- parent-inference 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 확정/거부","en":"Parent Review"}' WHERE rsrc_cd='parent-inference-workflow:parent-review';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"후보 제외","en":"Exclusion Management"}' WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"학습 세션","en":"Label Session"}' WHERE rsrc_cd='parent-inference-workflow:label-session';
-- ──────────────────────────────────────────────────────────────────
-- admin 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 모델관리","en":"AI Model Management"}' WHERE rsrc_cd='ai-operations:ai-model';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"MLOps","en":"MLOps"}' WHERE rsrc_cd='ai-operations:mlops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"LLM 운영","en":"LLM Operations"}' WHERE rsrc_cd='ai-operations:llm-ops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 의사결정 지원","en":"AI Assistant"}' WHERE rsrc_cd='ai-operations:ai-assistant';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"환경설정","en":"System Config"}' WHERE rsrc_cd='admin:system-config';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"데이터 허브","en":"Data Hub"}' WHERE rsrc_cd='admin:data-hub';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"외부 서비스","en":"External Service"}' WHERE rsrc_cd='statistics:external-service';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Admin Panel"}' WHERE rsrc_cd='admin:user-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"권한 관리","en":"Access Control"}' WHERE rsrc_cd='admin:permission-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"공지사항","en":"Notices"}' WHERE rsrc_cd='admin:notices';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감사 로그","en":"Audit Logs"}' WHERE rsrc_cd='admin:audit-logs';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"접근 이력","en":"Access Logs"}' WHERE rsrc_cd='admin:access-logs';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"로그인 이력","en":"Login History"}' WHERE rsrc_cd='admin:login-history';
-- ──────────────────────────────────────────────────────────────────
-- 메뉴 미표시 권한 노드 (권한 관리 UI에서만 표시)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감시","en":"Surveillance"}' WHERE rsrc_cd='surveillance';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"탐지","en":"Detection"}' WHERE rsrc_cd='detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박","en":"Vessel"}' WHERE rsrc_cd='vessel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험평가","en":"Risk Assessment"}' WHERE rsrc_cd='risk-assessment';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰","en":"Patrol"}' WHERE rsrc_cd='patrol';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속","en":"Enforcement"}' WHERE rsrc_cd='enforcement';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 운영","en":"AI Operations"}' WHERE rsrc_cd='ai-operations';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계","en":"Statistics"}' WHERE rsrc_cd='statistics';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박상세","en":"Vessel Detail"}' WHERE rsrc_cd='vessel:vessel-detail';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전재탐지","en":"Transfer Detection"}' WHERE rsrc_cd='vessel:transfer-detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"알림 목록","en":"Alert List"}' WHERE rsrc_cd='monitoring:alert-list';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"KPI 패널","en":"KPI Panel"}' WHERE rsrc_cd='monitoring:kpi-panel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구식별","en":"Gear Identification"}' WHERE rsrc_cd='detection:gear-identification';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"역할 관리","en":"Role Management"}' WHERE rsrc_cd='admin:role-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"메뉴 설정","en":"Menu Config"}' WHERE rsrc_cd='admin:menu-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전역 제외 관리","en":"Global Exclusion"}' WHERE rsrc_cd='parent-inference-workflow:exclusion-management';

파일 보기

@ -0,0 +1,22 @@
-- ============================================================================
-- V023: auth_perm_tree Level-0 sort_ord를 좌측 메뉴 순서와 일치
-- 권한 관리 UI와 사이드바 메뉴의 나열 순서를 동일하게 맞춤
-- ============================================================================
-- Level-0 노드 sort_ord를 메뉴 nav_sort 순서 기준으로 재배치
-- 메뉴 자식이 있는 그룹: 자식 nav_sort 최소값 기준으로 부모 위치 결정
UPDATE kcg.auth_perm_tree SET sort_ord = 100 WHERE rsrc_cd = 'dashboard'; -- nav_sort=100
UPDATE kcg.auth_perm_tree SET sort_ord = 200 WHERE rsrc_cd = 'monitoring'; -- nav_sort=200
UPDATE kcg.auth_perm_tree SET sort_ord = 300 WHERE rsrc_cd = 'surveillance'; -- 자식 nav_sort 300~400
UPDATE kcg.auth_perm_tree SET sort_ord = 500 WHERE rsrc_cd = 'risk-assessment'; -- 자식 nav_sort 500~600
UPDATE kcg.auth_perm_tree SET sort_ord = 700 WHERE rsrc_cd = 'detection'; -- 자식 nav_sort 700~900
UPDATE kcg.auth_perm_tree SET sort_ord = 950 WHERE rsrc_cd = 'statistics'; -- 자식 nav_sort 1200~1300
UPDATE kcg.auth_perm_tree SET sort_ord = 1000 WHERE rsrc_cd = 'enforcement'; -- 자식 nav_sort 1000~1100
UPDATE kcg.auth_perm_tree SET sort_ord = 1400 WHERE rsrc_cd = 'field-ops'; -- nav_sort=1400
UPDATE kcg.auth_perm_tree SET sort_ord = 1500 WHERE rsrc_cd = 'parent-inference-workflow'; -- nav_sort=1500
UPDATE kcg.auth_perm_tree SET sort_ord = 1600 WHERE rsrc_cd = 'admin'; -- nav_sort=1600
-- 메뉴 미표시 Level-0 노드: 관련 메뉴 순서 근처에 배치
UPDATE kcg.auth_perm_tree SET sort_ord = 650 WHERE rsrc_cd = 'vessel'; -- detection 뒤, 단속 앞
UPDATE kcg.auth_perm_tree SET sort_ord = 1350 WHERE rsrc_cd = 'patrol'; -- field-ops 바로 앞
UPDATE kcg.auth_perm_tree SET sort_ord = 1550 WHERE rsrc_cd = 'ai-operations'; -- admin 바로 앞

파일 보기

@ -0,0 +1,78 @@
-- ============================================================================
-- V024: 권한 트리 = 메뉴 트리 완전 동기화
-- 보이지 않는 도메인 그룹 8개 삭제, 자식을 메뉴 구조에 맞게 재배치
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 그룹 레벨 권한 → 개별 자식 권한으로 확장
-- 예: (VIEWER, detection, READ, Y) → 각 detection 자식에 READ Y 복사
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT ap.role_sn, c.rsrc_cd, ap.oper_cd, ap.grant_yn
FROM kcg.auth_perm ap
JOIN kcg.auth_perm_tree c ON c.parent_cd = ap.rsrc_cd
WHERE ap.rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
)
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 그룹 권한 행 삭제
-- ──────────────────────────────────────────────────────────────────
DELETE FROM kcg.auth_perm
WHERE rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
);
-- ──────────────────────────────────────────────────────────────────
-- 3. 자식 노드 parent_cd 재배치 (그룹 삭제 전에 수행)
-- ──────────────────────────────────────────────────────────────────
-- 최상위 평탄 아이템: parent_cd → NULL
UPDATE kcg.auth_perm_tree SET parent_cd = NULL
WHERE parent_cd IN (
'surveillance','detection','risk-assessment','enforcement','statistics','vessel'
);
-- patrol 자식 → field-ops 메뉴 그룹으로 이동
UPDATE kcg.auth_perm_tree SET parent_cd = 'field-ops'
WHERE parent_cd = 'patrol';
-- ai-operations 자식 → admin 메뉴 그룹으로 이동
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin'
WHERE parent_cd = 'ai-operations';
-- ──────────────────────────────────────────────────────────────────
-- 4. 그룹 노드 삭제 (자식이 모두 이동된 후)
-- ──────────────────────────────────────────────────────────────────
DELETE FROM kcg.auth_perm_tree
WHERE rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
);
-- ──────────────────────────────────────────────────────────────────
-- 5. 패널 노드 parent_cd를 실제 소속 페이지로 수정
-- ──────────────────────────────────────────────────────────────────
-- 전역 제외 관리 → 후보 제외 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'parent-inference-workflow:parent-exclusion'
WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management';
-- 역할 관리 → 권한 관리 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
WHERE rsrc_cd = 'admin:role-management';
-- 메뉴 설정 → 권한 관리 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
WHERE rsrc_cd = 'admin:menu-management';
-- 어구식별 → 어구 탐지 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'detection:gear-detection'
WHERE rsrc_cd = 'detection:gear-identification';
-- 전재탐지 → 선박상세 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'vessel:vessel-detail'
WHERE rsrc_cd = 'vessel:transfer-detection';

파일 보기

@ -1,6 +1,6 @@
# Database Migrations # Database Migrations
> ⚠️ **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/) > **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
> >
> Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다. > Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다.
> Spring Boot 기동 시 Flyway가 자동으로 적용합니다. > Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
@ -10,37 +10,208 @@
- **User**: `kcg-app` - **User**: `kcg-app`
- **Schema**: `kcg` - **Schema**: `kcg`
- **Host**: `211.208.115.83:5432` - **Host**: `211.208.115.83:5432`
- **현재 버전**: v022 (2026-04-09)
## 적용된 마이그레이션 (V001~V013) ---
### Phase 1~8: 인증/권한/감사 (V001~V007) ## 마이그레이션 히스토리 (V001~V022)
Flyway 마이그레이션은 **증분 방식** — 각 파일은 이전 버전에 대한 변경(ALTER/INSERT/CREATE)만 포함합니다.
V001이 처음 테이블을 만들고, 이후 파일들이 컬럼 추가·시드 INSERT·신규 테이블 생성 등을 수행합니다.
### 인증/권한/감사 (V001~V007)
| 파일 | 내용 | | 파일 | 내용 |
|---|---| |---|---|
| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 | | `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting |
| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 | | `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) |
| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 | | `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 |
| `V004__access_logs.sql` | 감사로그/접근이력 | | `V004__access_logs.sql` | auth_audit_log + auth_access_log |
| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) | | `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions |
| `V006__demo_accounts.sql` | 데모 계정 5종 | | `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) |
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 | | `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 |
### S1: 마스터 데이터 + Prediction 기반 (V008~V013) ### 마스터 데이터 (V008~V011)
| 파일 | 내용 | | 파일 | 내용 |
|---|---| |---|---|
| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) | | `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) |
| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) | | `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) |
| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) | | `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) |
| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) | | `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) |
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 |
| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) | ### Prediction 분석 (V012~V015)
| 파일 | 내용 |
|---|---|
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + prediction_events + alerts + stats(시/일/월) + KPI + risk_grid + label_input |
| `V013__enforcement_operations.sql` | enforcement_records + plans + patrol_assignments + ai_model_versions + metrics |
| `V014__fleet_prediction_tables.sql` | fleet_vessels/tracking_snapshot + gear_identity_log + correlation_scores/raw_metrics + correlation_param_models + group_polygon_snapshots + gear_group_episodes/episode_snapshots + parent_candidate_snapshots + label_tracking_cycles + system_config |
| `V015__fix_numeric_precision.sql` | NUMERIC 정밀도 확대 (점수/비율 컬럼) |
### 모선 워크플로우 확장 + 기능 추가 (V016~V019)
| 파일 | 내용 |
|---|---|
| `V016__parent_workflow_columns.sql` | gear_group_parent_resolution 확장 (confidence, decision_source, episode_id 등) |
| `V017__role_color_hex.sql` | auth_role.color_hex 컬럼 추가 |
| `V018__prediction_event_features.sql` | prediction_events.features JSONB 컬럼 추가 |
| `V019__llm_ops_perm.sql` | ai-operations:llm-ops 권한 트리 노드 + ADMIN 권한 |
### 메뉴 DB SSOT (V020~V022)
| 파일 | 내용 |
|---|---|
| `V020__menu_config.sql` | menu_config 테이블 생성 + 시드 (V021에서 통합 후 폐기) |
| `V021__menu_into_perm_tree.sql` | auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort) + 공유 리소스 분리 (statistics:reports, admin:data-hub, admin:notices) + menu_config DROP |
| `V022__perm_tree_i18n_labels.sql` | auth_perm_tree.labels JSONB 추가 — DB가 i18n SSOT (`{"ko":"...", "en":"..."}`) |
---
## 테이블 목록 (49개, flyway_schema_history 포함)
### 인증/권한 (8 테이블)
| 테이블 | PK | 설명 | 주요 컬럼 |
|---|---|---|---|
| `auth_user` | user_id (UUID) | 사용자 | user_acnt(UQ), pswd_hash, user_nm, rnkp_nm, email, org_sn(FK→auth_org), user_stts_cd, fail_cnt, auth_provider |
| `auth_org` | org_sn (BIGSERIAL) | 조직 | org_nm, org_abbr_nm, org_tp_cd, upper_org_sn(FK 자기참조) |
| `auth_role` | role_sn (BIGSERIAL) | 역할 | role_cd(UQ), role_nm, role_dc, dflt_yn, builtin_yn, color_hex |
| `auth_user_role` | (user_id, role_sn) | 사용자-역할 매핑 | granted_at, granted_by |
| `auth_perm_tree` | rsrc_cd (VARCHAR 100) | 권한 트리 + **메뉴 SSOT** | parent_cd(FK 자기참조), rsrc_nm, icon, rsrc_level, sort_ord, **url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort, labels(JSONB)** |
| `auth_perm` | perm_sn (BIGSERIAL) | 권한 매트릭스 | role_sn(FK→auth_role), rsrc_cd(FK→auth_perm_tree), oper_cd, grant_yn, UQ(role_sn,rsrc_cd,oper_cd) |
| `auth_setting` | setting_key (VARCHAR 50) | 시스템 설정 | setting_val(JSONB) |
| `auth_login_hist` | hist_sn (BIGSERIAL) | 로그인 이력 | user_id, user_acnt, login_dtm, login_ip, result, fail_reason, auth_provider |
### 감사 (2 테이블)
| 테이블 | PK | 설명 | 주요 컬럼 |
|---|---|---|---|
| `auth_audit_log` | audit_sn (BIGSERIAL) | 감사 로그 | user_id, action_cd, resource_type, resource_id, detail(JSONB), ip_address, result |
| `auth_access_log` | access_sn (BIGSERIAL) | API 접근 이력 | user_id, http_method, request_path, status_code, duration_ms, ip_address |
### 모선 워크플로우 (7 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_group_parent_resolution` | id (BIGSERIAL), UQ(group_key, sub_cluster_id) | 모선 확정/거부 결과 (status, selected_parent_mmsi, confidence, decision_source, scores, episode_id) |
| `gear_group_parent_review_log` | id (BIGSERIAL) | 운영자 리뷰 이력 (action, actor, comment) |
| `gear_parent_candidate_exclusions` | id (BIGSERIAL) | 후보 제외 관리 (scope_type, excluded_mmsi, reason, active_from/until) |
| `gear_parent_label_sessions` | id (BIGSERIAL) | 학습 세션 (label_parent_mmsi, status, duration_days, anchor_snapshot) |
| `gear_parent_label_tracking_cycles` | (label_session_id, observed_at) | 학습 추적 사이클 (top_candidate, labeled_candidate 비교) |
| `gear_group_episodes` | episode_id (VARCHAR 50) | 어구 그룹 에피소드 (lineage_key, status, member_mmsis, center_point) |
| `gear_group_episode_snapshots` | (episode_id, observed_at) | 에피소드 스냅샷 |
### 마스터 데이터 (5 테이블)
| 테이블 | PK | 설명 | 시드 |
|---|---|---|---|
| `code_master` | code_id (VARCHAR 100) | 계층형 코드 | 12그룹, 72코드 |
| `gear_type_master` | gear_code (VARCHAR 20) | 어구 유형 | 6종 |
| `zone_polygon_master` | zone_code (VARCHAR 30) | 해역 폴리곤 (PostGIS GEOMETRY 4326) | 8해역 |
| `vessel_permit_master` | mmsi (VARCHAR 20) | 어선 허가 | 9척 |
| `patrol_ship_master` | ship_id (BIGSERIAL), UQ(ship_code) | 함정 | 6척 |
### Prediction 이벤트/통계 (8 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `vessel_analysis_results` | (id, analyzed_at) 파티션 | 선박 분석 결과 (35컬럼: mmsi, risk_score, is_dark, transship_suspect, features JSONB 등) |
| `vessel_analysis_results_default` | — | 기본 파티션 |
| `prediction_events` | id (BIGSERIAL), UQ(event_uid) | 탐지 이벤트 (level, category, vessel_mmsi, status, features JSONB) |
| `prediction_alerts` | id (BIGSERIAL) | 경보 발송 (event_id FK, channel, delivery_status) |
| `event_workflow` | id (BIGSERIAL) | 이벤트 상태 변경 이력 (prev/new_status, actor) |
| `prediction_stats_hourly` | stat_hour (TIMESTAMPTZ) | 시간별 통계 (by_category/by_zone JSONB) |
| `prediction_stats_daily` | stat_date (DATE) | 일별 통계 |
| `prediction_stats_monthly` | stat_month (DATE) | 월별 통계 |
### Prediction 보조 (7 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `prediction_kpi_realtime` | kpi_key (VARCHAR 50) | 실시간 KPI (value, trend, delta_pct) |
| `prediction_risk_grid` | (cell_id, stat_hour) | 위험도 격자 |
| `prediction_label_input` | id (BIGSERIAL) | 학습 피드백 입력 |
| `gear_correlation_scores` | (model_id, group_key, sub_cluster_id, target_mmsi) | 어구-선박 상관 점수 |
| `gear_correlation_raw_metrics` | id (BIGSERIAL) | 상관 원시 지표 |
| `correlation_param_models` | id (BIGSERIAL) | 상관 모델 파라미터 |
| `group_polygon_snapshots` | id (BIGSERIAL) | 그룹 폴리곤 스냅샷 (PostGIS) |
### Prediction 후보 (1 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_group_parent_candidate_snapshots` | id (BIGSERIAL) | 모선 후보 스냅샷 (25컬럼: 점수 분해, evidence JSONB) |
### 단속/작전 (3 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `enforcement_records` | id (BIGSERIAL), UQ(enf_uid) | 단속 이력 (event_id FK, vessel_mmsi, action, result) |
| `enforcement_plans` | id (BIGSERIAL), UQ(plan_uid) | 단속 계획 (planned_date, risk_level, status) |
| `patrol_assignments` | id (BIGSERIAL) | 함정 배치 (ship_id FK, plan_id FK, waypoints JSONB) |
### AI 모델 (2 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `ai_model_versions` | id (BIGSERIAL) | AI 모델 버전 (accuracy, status, train_config JSONB) |
| `ai_model_metrics` | id (BIGSERIAL) | 모델 메트릭 (model_id FK, metric_name, metric_value) |
### Fleet (3 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `fleet_companies` | id (BIGSERIAL) | 선단 업체 (name_cn/en/ko, country) |
| `fleet_vessels` | id (BIGSERIAL) | 선단 선박 (company_id FK, mmsi, gear_code, fleet_role) |
| `fleet_tracking_snapshot` | id (BIGSERIAL) | 선단 추적 스냅샷 (company_id FK) |
### 기타 (2 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_identity_log` | id (BIGSERIAL) | 어구 식별 로그 (mmsi, name, parent_mmsi, match_method) |
| `system_config` | key (VARCHAR 100) | 시스템 설정 (value JSONB) |
---
## 인덱스 현황 (149개)
주요 패턴:
- **시계열 DESC**: `(occurred_at DESC)`, `(created_at DESC)`, `(analyzed_at DESC)` — 최신 데이터 우선 조회
- **복합 키**: `(group_key, sub_cluster_id, observed_at DESC)` — 어구 그룹 시계열
- **GiST 공간**: `polygon`, `polygon_geom` — PostGIS 공간 검색
- **GIN 배열**: `violation_categories` — 위반 카테고리 배열 검색
- **부분 인덱스**: `(released_at) WHERE released_at IS NULL` — 활성 제외만, `(is_dark) WHERE is_dark = true` — dark vessel만
## FK 관계 (21개)
```
auth_user ─→ auth_org (org_sn)
auth_user_role ─→ auth_user (user_id), auth_role (role_sn)
auth_perm ─→ auth_role (role_sn), auth_perm_tree (rsrc_cd)
auth_perm_tree ─→ auth_perm_tree (parent_cd, 자기참조)
code_master ─→ code_master (parent_id, 자기참조)
zone_polygon_master ─→ zone_polygon_master (parent_zone_code, 자기참조)
auth_org ─→ auth_org (upper_org_sn, 자기참조)
enforcement_records ─→ prediction_events (event_id), patrol_ship_master (patrol_ship_id)
event_workflow ─→ prediction_events (event_id)
prediction_alerts ─→ prediction_events (event_id)
patrol_assignments ─→ patrol_ship_master (ship_id), enforcement_plans (plan_id)
ai_model_metrics ─→ ai_model_versions (model_id)
gear_correlation_scores ─→ correlation_param_models (model_id)
gear_parent_label_tracking_cycles ─→ gear_parent_label_sessions (label_session_id)
fleet_tracking_snapshot ─→ fleet_companies (company_id)
fleet_vessels ─→ fleet_companies (company_id)
vessel_permit_master ─→ fleet_companies (company_id)
```
---
## 실행 방법 ## 실행 방법
### 최초 1회 - DB/사용자 생성 (관리자 권한 필요) ### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
```sql ```sql
-- snp 관리자 계정으로 접속
psql -h 211.208.115.83 -U snp -d postgres psql -h 211.208.115.83 -U snp -d postgres
CREATE DATABASE kcgaidb; CREATE DATABASE kcgaidb;
@ -61,7 +232,11 @@ cd backend && ./mvnw spring-boot:run
### 수동 적용 ### 수동 적용
```bash ```bash
cd backend && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb -Dflyway.user=kcg-app -Dflyway.password=Kcg2026ai -Dflyway.schemas=kcg cd backend && ./mvnw flyway:migrate \
-Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb \
-Dflyway.user=kcg-app \
-Dflyway.password=Kcg2026ai \
-Dflyway.schemas=kcg
``` ```
### Checksum 불일치 시 (마이그레이션 파일 수정 후) ### Checksum 불일치 시 (마이그레이션 파일 수정 후)
@ -70,4 +245,18 @@ cd backend && ./mvnw flyway:repair -Dflyway.url=... (위와 동일)
``` ```
## 신규 마이그레이션 추가 ## 신규 마이그레이션 추가
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다. [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V0NN__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.
### 메뉴 추가 시 필수 포함 사항
auth_perm_tree에 INSERT 시 메뉴 SSOT 컬럼도 함께 지정:
```sql
INSERT INTO kcg.auth_perm_tree(
rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, icon,
url_path, label_key, component_key, nav_group, nav_sort,
labels
) VALUES (
'new-feature:sub', 'new-feature', '새 기능', 1, 10, 'Sparkles',
'/new-feature/sub', 'nav.newFeatureSub', 'features/new-feature/SubPage', NULL, 1400,
'{"ko":"새 기능 서브","en":"New Feature Sub"}'
);
```

파일 보기

@ -4,6 +4,57 @@
## [Unreleased] ## [Unreleased]
### 추가
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
- **VesselAnalysis 직접 조회 API 5개** (`/api/analysis/*`) — iran proxy 없이 prediction DB 직접 조회
- vessels 목록 (필터: mmsi, zone, riskLevel, isDark)
- vessels/{mmsi} 최신 분석 (features JSONB 포함)
- vessels/{mmsi}/history 분석 이력 (24h)
- dark 베셀 목록 (MMSI 중복 제거)
- transship 의심 목록
- **EventList 인라인 액션 4종** — 확인(ACK)/선박상세/단속등록/오탐 처리
- **MMSI → VesselDetail 링크** — EventList, DarkVessel, EnforcementHistory 3개 화면
- **VesselDetail 전면 개편** — prediction 직접 API 전환, dark 패턴 시각화(tier/score/patterns), 환적 분석, 24h AIS 수신 타임라인, 단속 이력 탭
- **DarkVesselDetection prediction 전환** — iran proxy 제거, tier 기반 KPI/필터/정렬
- **EnforcementHistory eventId 역추적** — 단속→이벤트 역링크
- **EnforcementPlan 미배정 CRITICAL 이벤트 패널** — NEW 상태 CRITICAL 이벤트 표시
- **모선추론 자동 연결** — CONFIRM→LabelSession, REJECT→Exclusion 자동 호출
- **30초 자동 갱신** — EventList, DarkVessel (silentRefresh 패턴, 깜박임 없음)
- **admin 메뉴 4개 서브그룹** — AI 플랫폼/시스템 운영/사용자 관리/감사·보안
- **V018 마이그레이션** — prediction_events.features JSONB 컬럼
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
- 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection 등) → 권한 트리 = 메뉴 트리 완전 동기화
- 패널 노드 parent_cd 실제 소속 페이지로 수정 (어구식별→어구탐지, 전역제외→후보제외)
- vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크)
- 공유 리소스 분리: statistics:reports, admin:data-hub, admin:notices 독립 노드 생성
- V020~V024 마이그레이션 5건
- **프론트엔드 동적 메뉴/라우팅** — DB 기반 자동 구성
- menuStore(Zustand) + componentRegistry(lazy loading) + iconRegistry
- NAV_ENTRIES/PATH_TO_RESOURCE 하드코딩 제거
- App.tsx DynamicRoutes: DB menuConfig에서 Route 자동 생성
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
### 변경
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
- **EnforcementController** vesselMmsi 필터 파라미터 추가
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
### 수정
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
- system-flow 08-frontend.json 누락 노드 14개 추가
### 문서
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
## [2026-04-09] ## [2026-04-09]
### 추가 ### 추가

파일 보기

@ -1,42 +1,13 @@
import { Suspense, useMemo, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/app/auth/AuthContext'; import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
import { MainLayout } from '@/app/layout/MainLayout'; import { MainLayout } from '@/app/layout/MainLayout';
import { LoginPage } from '@features/auth'; import { LoginPage } from '@features/auth';
/* SFR-01 */ import { AccessControl } from '@features/admin'; import { useMenuStore } from '@stores/menuStore';
/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin'; import { COMPONENT_REGISTRY } from '@/app/componentRegistry';
/* SFR-03 */ import { DataHub } from '@features/admin';
/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations'; // 권한 노드 없는 드릴다운 라우트 (인증만 체크)
/* SFR-05 */ import { RiskMap } from '@features/risk-assessment'; const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail })));
/* SFR-06 */ import { EnforcementPlan } from '@features/risk-assessment';
/* SFR-07 */ import { PatrolRoute } from '@features/patrol';
/* SFR-08 */ import { FleetOptimization } from '@features/patrol';
/* SFR-09 */ import { DarkVesselDetection } from '@features/detection';
/* SFR-10 */ import { GearDetection } from '@features/detection';
/* SFR-11 */ import { EnforcementHistory } from '@features/enforcement';
/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring';
/* SFR-13 */ import { Statistics } from '@features/statistics';
/* SFR-14 */ import { ExternalService } from '@features/statistics';
/* SFR-15 */ import { MobileService } from '@features/field-ops';
/* SFR-16 */ import { ShipAgent } from '@features/field-ops';
/* SFR-17 */ import { AIAlert } from '@features/field-ops';
/* SFR-18+19 */ import { MLOpsPage } from '@features/ai-operations';
/* SFR-20 */ import { AIAssistant } from '@features/ai-operations';
/* SFR-20 LLM운영 */ import { LLMOpsPage } from '@features/ai-operations';
/* 기존 */ import { Dashboard } from '@features/dashboard';
import { LiveMapView, MapControl } from '@features/surveillance';
import { EventList } from '@features/enforcement';
import { VesselDetail } from '@features/vessel';
import { ChinaFishing } from '@features/detection';
import { ReportManagement } from '@features/statistics';
import { AdminPanel } from '@features/admin';
// Phase 4: 모선 워크플로우
import { ParentReview } from '@features/parent-inference/ParentReview';
import { ParentExclusion } from '@features/parent-inference/ParentExclusion';
import { LabelSession } from '@features/parent-inference/LabelSession';
// Phase 4: 관리자 로그
import { AuditLogs } from '@features/admin/AuditLogs';
import { AccessLogs } from '@features/admin/AccessLogs';
import { LoginHistoryView } from '@features/admin/LoginHistoryView';
/** /**
* . * .
@ -69,66 +40,66 @@ function ProtectedRoute({
return <>{children}</>; return <>{children}</>;
} }
function LoadingFallback() {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="text-sm text-hint"> ...</div>
</div>
);
}
/**
* DB menu_config Route .
* React Router v6는 <Routes> <Route> .
*/
function useDynamicRoutes() {
const items = useMenuStore((s) => s.items);
const routableItems = useMemo(
() => items.filter((i) => i.menuType === 'ITEM' && i.urlPath),
[items],
);
return routableItems.map((item) => {
const Comp = item.componentKey ? COMPONENT_REGISTRY[item.componentKey] : null;
if (!Comp || !item.urlPath) return null;
const path = item.urlPath.replace(/^\//, '');
return (
<Route
key={item.menuCd}
path={path}
element={
<ProtectedRoute resource={item.rsrcCd ?? undefined}>
<Suspense fallback={<LoadingFallback />}>
<Comp />
</Suspense>
</ProtectedRoute>
}
/>
);
});
}
function AppRoutes() {
const dynamicRoutes = useDynamicRoutes();
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{dynamicRoutes}
{/* 드릴다운 전용 라우트 — 메뉴/권한 노드 없음, 인증만 체크 */}
<Route path="vessel/:id" element={<Suspense fallback={<LoadingFallback />}><VesselDetail /></Suspense>} />
</Route>
</Routes>
);
}
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Routes> <AppRoutes />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{/* SFR-12 대시보드 */}
<Route path="dashboard" element={<ProtectedRoute resource="dashboard"><Dashboard /></ProtectedRoute>} />
<Route path="monitoring" element={<ProtectedRoute resource="monitoring"><MonitoringDashboard /></ProtectedRoute>} />
{/* SFR-05~06 위험도·단속계획 */}
<Route path="risk-map" element={<ProtectedRoute resource="risk-assessment:risk-map"><RiskMap /></ProtectedRoute>} />
<Route path="enforcement-plan" element={<ProtectedRoute resource="risk-assessment:enforcement-plan"><EnforcementPlan /></ProtectedRoute>} />
{/* SFR-09~10 탐지 */}
<Route path="dark-vessel" element={<ProtectedRoute resource="detection:dark-vessel"><DarkVesselDetection /></ProtectedRoute>} />
<Route path="gear-detection" element={<ProtectedRoute resource="detection:gear-detection"><GearDetection /></ProtectedRoute>} />
<Route path="china-fishing" element={<ProtectedRoute resource="detection:china-fishing"><ChinaFishing /></ProtectedRoute>} />
{/* SFR-07~08 순찰경로 */}
<Route path="patrol-route" element={<ProtectedRoute resource="patrol:patrol-route"><PatrolRoute /></ProtectedRoute>} />
<Route path="fleet-optimization" element={<ProtectedRoute resource="patrol:fleet-optimization"><FleetOptimization /></ProtectedRoute>} />
{/* SFR-11 이력 */}
<Route path="enforcement-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><EnforcementHistory /></ProtectedRoute>} />
<Route path="event-list" element={<ProtectedRoute resource="enforcement:event-list"><EventList /></ProtectedRoute>} />
{/* SFR-15~17 현장 대응 */}
<Route path="mobile-service" element={<ProtectedRoute resource="field-ops:mobile-service"><MobileService /></ProtectedRoute>} />
<Route path="ship-agent" element={<ProtectedRoute resource="field-ops:ship-agent"><ShipAgent /></ProtectedRoute>} />
<Route path="ai-alert" element={<ProtectedRoute resource="field-ops:ai-alert"><AIAlert /></ProtectedRoute>} />
{/* SFR-13~14 통계·외부연계 */}
<Route path="statistics" element={<ProtectedRoute resource="statistics:statistics"><Statistics /></ProtectedRoute>} />
<Route path="external-service" element={<ProtectedRoute resource="statistics:external-service"><ExternalService /></ProtectedRoute>} />
<Route path="reports" element={<ProtectedRoute resource="statistics:statistics"><ReportManagement /></ProtectedRoute>} />
{/* SFR-04 AI 모델 */}
<Route path="ai-model" element={<ProtectedRoute resource="ai-operations:ai-model"><AIModelManagement /></ProtectedRoute>} />
{/* SFR-18~20 AI 운영 */}
<Route path="mlops" element={<ProtectedRoute resource="ai-operations:mlops"><MLOpsPage /></ProtectedRoute>} />
<Route path="llm-ops" element={<ProtectedRoute resource="ai-operations:llm-ops"><LLMOpsPage /></ProtectedRoute>} />
<Route path="ai-assistant" element={<ProtectedRoute resource="ai-operations:ai-assistant"><AIAssistant /></ProtectedRoute>} />
{/* SFR-03 데이터허브 */}
<Route path="data-hub" element={<ProtectedRoute resource="admin:system-config"><DataHub /></ProtectedRoute>} />
{/* SFR-02 환경설정 */}
<Route path="system-config" element={<ProtectedRoute resource="admin:system-config"><SystemConfig /></ProtectedRoute>} />
<Route path="notices" element={<ProtectedRoute resource="admin"><NoticeManagement /></ProtectedRoute>} />
{/* SFR-01 권한·시스템 */}
<Route path="access-control" element={<ProtectedRoute resource="admin:permission-management"><AccessControl /></ProtectedRoute>} />
<Route path="admin" element={<ProtectedRoute resource="admin"><AdminPanel /></ProtectedRoute>} />
{/* Phase 4: 관리자 로그 */}
<Route path="admin/audit-logs" element={<ProtectedRoute resource="admin:audit-logs"><AuditLogs /></ProtectedRoute>} />
<Route path="admin/access-logs" element={<ProtectedRoute resource="admin:access-logs"><AccessLogs /></ProtectedRoute>} />
<Route path="admin/login-history" element={<ProtectedRoute resource="admin:login-history"><LoginHistoryView /></ProtectedRoute>} />
{/* Phase 4: 모선 워크플로우 */}
<Route path="parent-inference/review" element={<ProtectedRoute resource="parent-inference-workflow:parent-review"><ParentReview /></ProtectedRoute>} />
<Route path="parent-inference/exclusion" element={<ProtectedRoute resource="parent-inference-workflow:parent-exclusion"><ParentExclusion /></ProtectedRoute>} />
<Route path="parent-inference/label-session" element={<ProtectedRoute resource="parent-inference-workflow:label-session"><LabelSession /></ProtectedRoute>} />
{/* 기존 유지 */}
<Route path="events" element={<ProtectedRoute resource="surveillance:live-map"><LiveMapView /></ProtectedRoute>} />
<Route path="map-control" element={<ProtectedRoute resource="surveillance:map-control"><MapControl /></ProtectedRoute>} />
<Route path="vessel/:id" element={<ProtectedRoute resource="vessel:vessel-detail"><VesselDetail /></ProtectedRoute>} />
</Route>
</Routes>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
); );

파일 보기

@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi'; import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
import { useMenuStore } from '@stores/menuStore';
/* /*
* SFR-01: 시스템 * SFR-01: 시스템
@ -33,47 +34,6 @@ export interface AuthUser {
// ─── 세션 타임아웃 (30분) ────────────────── // ─── 세션 타임아웃 (30분) ──────────────────
const SESSION_TIMEOUT = 30 * 60 * 1000; const SESSION_TIMEOUT = 30 * 60 * 1000;
// 경로 → 권한 리소스 매핑 (ProtectedRoute용)
const PATH_TO_RESOURCE: Record<string, string> = {
'/dashboard': 'dashboard',
'/monitoring': 'monitoring',
'/events': 'surveillance:live-map',
'/map-control': 'surveillance:map-control',
'/dark-vessel': 'detection:dark-vessel',
'/gear-detection': 'detection:gear-detection',
'/china-fishing': 'detection:china-fishing',
'/vessel': 'vessel',
'/risk-map': 'risk-assessment:risk-map',
'/enforcement-plan': 'risk-assessment:enforcement-plan',
'/patrol-route': 'patrol:patrol-route',
'/fleet-optimization': 'patrol:fleet-optimization',
'/enforcement-history': 'enforcement:enforcement-history',
'/event-list': 'enforcement:event-list',
'/mobile-service': 'field-ops:mobile-service',
'/ship-agent': 'field-ops:ship-agent',
'/ai-alert': 'field-ops:ai-alert',
'/ai-assistant': 'ai-operations:ai-assistant',
'/ai-model': 'ai-operations:ai-model',
'/mlops': 'ai-operations:mlops',
'/llm-ops': 'ai-operations:llm-ops',
'/statistics': 'statistics:statistics',
'/external-service': 'statistics:external-service',
'/admin/audit-logs': 'admin:audit-logs',
'/admin/access-logs': 'admin:access-logs',
'/admin/login-history': 'admin:login-history',
'/admin': 'admin',
'/access-control': 'admin:permission-management',
'/system-config': 'admin:system-config',
'/notices': 'admin',
'/reports': 'statistics:statistics',
'/data-hub': 'admin:system-config',
// 모선 워크플로우
'/parent-inference/review': 'parent-inference-workflow:parent-review',
'/parent-inference/exclusion': 'parent-inference-workflow:parent-exclusion',
'/parent-inference/label-session': 'parent-inference-workflow:label-session',
'/parent-inference': 'parent-inference-workflow',
};
interface AuthContextType { interface AuthContextType {
user: AuthUser | null; user: AuthUser | null;
loading: boolean; loading: boolean;
@ -133,7 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let alive = true; let alive = true;
fetchMe() fetchMe()
.then((b) => { .then((b) => {
if (alive && b) setUser(backendToAuthUser(b)); if (alive && b) {
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
}
}) })
.finally(() => { .finally(() => {
if (alive) setLoading(false); if (alive) setLoading(false);
@ -175,6 +138,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
const b = await loginApi(account, password); const b = await loginApi(account, password);
setUser(backendToAuthUser(b)); setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
setLastActivity(Date.now()); setLastActivity(Date.now());
} catch (e) { } catch (e) {
if (e instanceof LoginError) throw e; if (e instanceof LoginError) throw e;
@ -187,6 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await logoutApi(); await logoutApi();
} finally { } finally {
setUser(null); setUser(null);
useMenuStore.getState().clear();
} }
}, []); }, []);
@ -201,10 +166,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const hasAccess = useCallback( const hasAccess = useCallback(
(path: string) => { (path: string) => {
if (!user) return false; if (!user) return false;
// 경로의 첫 세그먼트로 매핑 // DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체)
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p)); const resource = useMenuStore.getState().getResourceForPath(path);
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능) if (!resource) return true;
const resource = PATH_TO_RESOURCE[matched];
return hasPermission(resource, 'READ'); return hasPermission(resource, 'READ');
}, },
[user, hasPermission], [user, hasPermission],

파일 보기

@ -0,0 +1,136 @@
import { lazy, type ComponentType } from 'react';
type LazyComponent = React.LazyExoticComponent<ComponentType<unknown>>;
/**
* DB menu_config.component_key lazy-loaded React .
* 1 .
*/
export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
// ── 상황판·감시 ──
'features/dashboard/Dashboard': lazy(() =>
import('@features/dashboard/Dashboard').then((m) => ({ default: m.Dashboard })),
),
'features/monitoring/MonitoringDashboard': lazy(() =>
import('@features/monitoring/MonitoringDashboard').then((m) => ({
default: m.MonitoringDashboard,
})),
),
'features/surveillance/LiveMapView': lazy(() =>
import('@features/surveillance').then((m) => ({ default: m.LiveMapView })),
),
'features/surveillance/MapControl': lazy(() =>
import('@features/surveillance').then((m) => ({ default: m.MapControl })),
),
// ── 위험도·단속 ──
'features/risk-assessment/RiskMap': lazy(() =>
import('@features/risk-assessment').then((m) => ({ default: m.RiskMap })),
),
'features/risk-assessment/EnforcementPlan': lazy(() =>
import('@features/risk-assessment').then((m) => ({ default: m.EnforcementPlan })),
),
// ── 탐지 ──
'features/detection/DarkVesselDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.DarkVesselDetection })),
),
'features/detection/GearDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearDetection })),
),
'features/detection/ChinaFishing': lazy(() =>
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
),
// ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
),
'features/enforcement/EventList': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EventList })),
),
// ── 통계 ──
'features/statistics/Statistics': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.Statistics })),
),
'features/statistics/ReportManagement': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.ReportManagement })),
),
'features/statistics/ExternalService': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.ExternalService })),
),
// ── 순찰 ──
'features/patrol/PatrolRoute': lazy(() =>
import('@features/patrol').then((m) => ({ default: m.PatrolRoute })),
),
'features/patrol/FleetOptimization': lazy(() =>
import('@features/patrol').then((m) => ({ default: m.FleetOptimization })),
),
// ── 현장작전 ──
'features/field-ops/AIAlert': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.AIAlert })),
),
'features/field-ops/MobileService': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.MobileService })),
),
'features/field-ops/ShipAgent': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.ShipAgent })),
),
// ── AI 운영 ──
'features/ai-operations/AIModelManagement': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.AIModelManagement })),
),
'features/ai-operations/MLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
),
'features/ai-operations/LLMOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
),
'features/ai-operations/AIAssistant': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.AIAssistant })),
),
// ── 관리 ──
'features/admin/AdminPanel': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AdminPanel })),
),
'features/admin/SystemConfig': lazy(() =>
import('@features/admin').then((m) => ({ default: m.SystemConfig })),
),
'features/admin/DataHub': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataHub })),
),
'features/admin/AccessControl': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AccessControl })),
),
'features/admin/NoticeManagement': lazy(() =>
import('@features/admin').then((m) => ({ default: m.NoticeManagement })),
),
'features/admin/AuditLogs': lazy(() =>
import('@features/admin/AuditLogs').then((m) => ({ default: m.AuditLogs })),
),
'features/admin/AccessLogs': lazy(() =>
import('@features/admin/AccessLogs').then((m) => ({ default: m.AccessLogs })),
),
'features/admin/LoginHistoryView': lazy(() =>
import('@features/admin/LoginHistoryView').then((m) => ({
default: m.LoginHistoryView,
})),
),
// ── 모선 워크플로우 ──
'features/parent-inference/ParentReview': lazy(() =>
import('@features/parent-inference/ParentReview').then((m) => ({
default: m.ParentReview,
})),
),
'features/parent-inference/ParentExclusion': lazy(() =>
import('@features/parent-inference/ParentExclusion').then((m) => ({
default: m.ParentExclusion,
})),
),
'features/parent-inference/LabelSession': lazy(() =>
import('@features/parent-inference/LabelSession').then((m) => ({
default: m.LabelSession,
})),
),
// ── 선박 (숨김 라우트) ──
'features/vessel/VesselDetail': lazy(() =>
import('@features/vessel').then((m) => ({ default: m.VesselDetail })),
),
};

파일 보기

@ -0,0 +1,54 @@
import {
LayoutDashboard, Activity, Radar, Map, Layers, Shield,
EyeOff, Anchor, Ship, FileText, List, BarChart3,
Navigation, Users, Send, Smartphone, Monitor,
GitBranch, CheckSquare, Ban, Tag, Settings,
Brain, Cpu, MessageSquare, Database, Wifi, Globe,
Fingerprint, Megaphone, ScrollText, History, KeyRound,
type LucideIcon,
} from 'lucide-react';
/**
* DB icon Lucide React .
* .
*/
const ICON_MAP: Record<string, LucideIcon> = {
LayoutDashboard,
Activity,
Radar,
Map,
Layers,
Shield,
EyeOff,
Anchor,
Ship,
FileText,
List,
BarChart3,
Navigation,
Users,
Send,
Smartphone,
Monitor,
GitBranch,
CheckSquare,
Ban,
Tag,
Settings,
Brain,
Cpu,
MessageSquare,
Database,
Wifi,
Globe,
Fingerprint,
Megaphone,
ScrollText,
History,
KeyRound,
};
export function resolveIcon(name: string | null): LucideIcon | null {
if (!name) return null;
return ICON_MAP[name] ?? null;
}

파일 보기

@ -2,18 +2,16 @@ import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { import {
LayoutDashboard, Map, List, Ship, Anchor, Radar, LogOut, ChevronLeft, ChevronRight,
FileText, Settings, LogOut, ChevronLeft, ChevronRight, Shield, Bell, Search, Clock, Lock,
Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers, Download, FileSpreadsheet, Printer,
Download, FileSpreadsheet, Printer, Wifi, Brain, Activity,
Navigation, Users, EyeOff, BarChart3, Globe,
Smartphone, Monitor, Send, Cpu, MessageSquare,
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext'; import { useAuth } from '@/app/auth/AuthContext';
import { getRoleColorHex } from '@shared/constants/userRoles'; import { getRoleColorHex } from '@shared/constants/userRoles';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore';
import { resolveIcon } from '@/app/iconRegistry';
/* /*
* SFR-01 : * SFR-01 :
@ -34,74 +32,6 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
sso: 'SSO', sso: 'SSO',
}; };
interface NavItem { to: string; icon: React.ElementType; labelKey: string; }
interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; }
type NavEntry = NavItem | NavGroup;
const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry;
const NAV_ENTRIES: NavEntry[] = [
// ── 상황판·감시 ──
{ to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' },
{ to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' },
{ to: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
// ── 위험도·단속 ──
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
{ to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' },
// ── 탐지 ──
{ to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' },
{ to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' },
{ to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' },
// ── 이력·통계 ──
{ to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' },
{ to: '/event-list', icon: List, labelKey: 'nav.eventList' },
{ to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' },
{ to: '/reports', icon: FileText, labelKey: 'nav.reports' },
// ── 함정용 (그룹) ──
{
groupKey: 'group.fieldOps', icon: Ship,
items: [
{ to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' },
{ to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' },
{ to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' },
{ to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' },
{ to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' },
],
},
// ── 모선 워크플로우 (운영자 의사결정, 그룹) ──
{
groupKey: 'group.parentInference', icon: GitBranch,
items: [
{ to: '/parent-inference/review', icon: CheckSquare, labelKey: 'nav.parentReview' },
{ to: '/parent-inference/exclusion', icon: Ban, labelKey: 'nav.parentExclusion' },
{ to: '/parent-inference/label-session', icon: Tag, labelKey: 'nav.labelSession' },
],
},
// ── 관리자 (그룹) ──
{
groupKey: 'group.admin', icon: Settings,
items: [
{ to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' },
{ to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' },
{ to: '/llm-ops', icon: Brain, labelKey: 'nav.llmOps' },
{ to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' },
{ to: '/external-service', icon: Globe, labelKey: 'nav.externalService' },
{ to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' },
{ to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' },
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
{ to: '/admin', icon: Settings, labelKey: 'nav.admin' },
{ to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' },
{ to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' },
{ to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' },
{ to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' },
],
},
];
// getPageLabel용 flat 목록
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]);
function formatRemaining(seconds: number) { function formatRemaining(seconds: number) {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = seconds % 60; const s = seconds % 60;
@ -116,11 +46,13 @@ export function MainLayout() {
const location = useLocation(); const location = useLocation();
const { user, logout, hasAccess, sessionRemaining } = useAuth(); const { user, logout, hasAccess, sessionRemaining } = useAuth();
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const { getTopLevelEntries, getChildren } = useMenuStore();
// getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n) // getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반)
const getPageLabel = (pathname: string): string => { const getPageLabel = (pathname: string): string => {
const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to)); const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath);
return item ? t(item.labelKey) : ''; const item = allItems.find((n) => pathname.startsWith(n.urlPath!));
return item ? getMenuLabel(item, language) : '';
}; };
// 공통 검색 // 공통 검색
@ -251,64 +183,31 @@ export function MainLayout() {
</div> </div>
)} )}
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */} {/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5"> <nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{NAV_ENTRIES.map((entry) => { {getTopLevelEntries().map((entry) => {
if (isGroup(entry)) { if (entry.menuType === 'GROUP') {
// 그룹 내 RBAC 필터링
const groupItems = entry.items.filter((item) => hasAccess(item.to));
if (groupItems.length === 0) return null;
const GroupIcon = entry.icon;
const isAnyActive = groupItems.some((item) => location.pathname.startsWith(item.to));
return ( return (
<div key={entry.groupKey}> <GroupMenu
{/* 그룹 헤더 */} key={entry.menuCd}
<button group={entry}
onClick={() => toggleGroup(entry.groupKey)} children={getChildren(entry.menuCd)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${ collapsed={collapsed}
isAnyActive || openGroups.has(entry.groupKey) hasAccess={hasAccess}
? 'text-foreground bg-surface-overlay' openGroups={openGroups}
: 'text-hint hover:bg-surface-overlay hover:text-label' toggleGroup={toggleGroup}
}`} location={location}
> language={language}
<GroupIcon className="w-4 h-4 shrink-0" /> />
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.groupKey)}</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${openGroups.has(entry.groupKey) || isAnyActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
{/* 그룹 하위 메뉴 */}
{(openGroups.has(entry.groupKey) || isAnyActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{groupItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
<item.icon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(item.labelKey)}</span>}
</NavLink>
))}
</div>
)}
</div>
); );
} }
// 일반 메뉴 아이템 // 일반 ITEM
if (!hasAccess(entry.to)) return null; if (!entry.urlPath || !hasAccess(entry.urlPath)) return null;
const Icon = resolveIcon(entry.icon);
return ( return (
<NavLink <NavLink
key={entry.to} key={entry.menuCd}
to={entry.to} to={entry.urlPath}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${ `flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
isActive isActive
@ -317,8 +216,8 @@ export function MainLayout() {
}` }`
} }
> >
<entry.icon className="w-4 h-4 shrink-0" /> {Icon && <Icon className="w-4 h-4 shrink-0" />}
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</span>} {!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(entry, language)}</span>}
</NavLink> </NavLink>
); );
})} })}
@ -503,3 +402,88 @@ export function MainLayout() {
</div> </div>
); );
} }
/* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */
function GroupMenu({
group,
children,
collapsed,
hasAccess,
openGroups,
toggleGroup,
location,
language,
}: {
group: MenuConfigItem;
children: MenuConfigItem[];
collapsed: boolean;
hasAccess: (path: string) => boolean;
openGroups: Set<string>;
toggleGroup: (name: string) => void;
location: { pathname: string };
language: string;
}) {
const navItems = children.filter((c) => c.menuType === 'ITEM' && c.urlPath);
const accessibleItems = navItems.filter((c) => hasAccess(c.urlPath!));
if (accessibleItems.length === 0) return null;
const GroupIcon = resolveIcon(group.icon);
const isAnyActive = accessibleItems.some((c) => location.pathname.startsWith(c.urlPath!));
const isOpen = openGroups.has(group.menuCd) || isAnyActive;
return (
<div>
<button
type="button"
onClick={() => toggleGroup(group.menuCd)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isOpen ? 'text-foreground bg-surface-overlay' : 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
{GroupIcon && <GroupIcon className="w-4 h-4 shrink-0" />}
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">
{getMenuLabel(group, language)}
</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</>
)}
</button>
{isOpen && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{children.map((child) => {
if (child.menuType === 'DIVIDER') {
if (collapsed) return null;
return (
<div key={child.menuCd} className="pt-2 pb-0.5 px-2.5">
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{child.dividerLabel}</span>
</div>
);
}
if (!child.urlPath || !hasAccess(child.urlPath)) return null;
const ChildIcon = resolveIcon(child.icon);
return (
<NavLink
key={child.menuCd}
to={child.urlPath}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
{ChildIcon && <ChildIcon className="w-3.5 h-3.5 shrink-0" />}
{!collapsed && (
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(child, language)}</span>
)}
</NavLink>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useMemo } from 'react'; import { Fragment, useEffect, useState, useCallback, useMemo } from 'react';
import { import {
Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown, Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown,
ExternalLink, Layers,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
@ -14,6 +15,7 @@ import {
type Operation, type TreeNode, type PermRow, type Operation, type TreeNode, type PermRow,
} from '@/lib/permission/permResolver'; } from '@/lib/permission/permResolver';
import { useAuth } from '@/app/auth/AuthContext'; import { useAuth } from '@/app/auth/AuthContext';
import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles'; import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker'; import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi'; import { updateRole as apiUpdateRole } from '@/services/adminApi';
@ -100,7 +102,7 @@ export function PermissionsPanel() {
setDraftPerms(m); setDraftPerms(m);
}, [selectedRole]); }, [selectedRole]);
// 트리 → 트리 인덱싱 (parent → children) // 트리 → 트리 인덱싱 (parent → children), nav_sort 기반 정렬 (메뉴 순서 일치)
const childrenMap = useMemo(() => { const childrenMap = useMemo(() => {
const m = new Map<string | null, PermTreeNode[]>(); const m = new Map<string | null, PermTreeNode[]>();
for (const n of tree) { for (const n of tree) {
@ -109,6 +111,14 @@ export function PermissionsPanel() {
arr.push(n); arr.push(n);
m.set(n.parentCd, arr); m.set(n.parentCd, arr);
} }
// nav_sort > 0 우선 (메뉴 표시 항목), 그 다음 sort_ord — 좌측 메뉴 순서와 일치
for (const [, arr] of m.entries()) {
arr.sort((a, b) => {
const aSort = (a.navSort > 0) ? a.navSort : 10000 + a.sortOrd;
const bSort = (b.navSort > 0) ? b.navSort : 10000 + b.sortOrd;
return aSort - bSort;
});
}
return m; return m;
}, [tree]); }, [tree]);
@ -280,9 +290,13 @@ export function PermissionsPanel() {
const hasChildren = children.length > 0; const hasChildren = children.length > 0;
const isExpanded = expanded.has(node.rsrcCd); const isExpanded = expanded.has(node.rsrcCd);
// DB labels JSONB에서 현재 언어 라벨 사용, 없으면 rsrcNm 폴백
const lang = useSettingsStore.getState().language;
const displayName = node.labels?.[lang] || node.labels?.ko || node.rsrcNm;
return ( return (
<> <Fragment key={node.rsrcCd}>
<tr key={node.rsrcCd} className="border-t border-border hover:bg-surface-overlay/30"> <tr className="border-t border-border hover:bg-surface-overlay/30">
<td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}> <td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{hasChildren ? ( {hasChildren ? (
@ -291,8 +305,14 @@ export function PermissionsPanel() {
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />} {isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button> </button>
) : <span className="w-4" />} ) : <span className="w-4" />}
<span className="text-[11px] text-heading font-medium">{node.rsrcNm}</span> {/* 페이지/패널 구분 아이콘 */}
{node.urlPath
? <span title="별도 페이지"><ExternalLink className="w-3 h-3 text-cyan-500/60 shrink-0" /></span>
: depth > 0 ? <span title="페이지 내 패널"><Layers className="w-3 h-3 text-amber-500/50 shrink-0" /></span> : null
}
<span className="text-[11px] text-heading font-medium">{displayName}</span>
<span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span> <span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span>
{node.urlPath && <span className="text-[8px] text-cyan-500/70 font-mono">{node.urlPath}</span>}
</div> </div>
</td> </td>
{OPERATIONS.map((op) => { {OPERATIONS.map((op) => {
@ -324,7 +344,7 @@ export function PermissionsPanel() {
})} })}
</tr> </tr>
{isExpanded && children.map((c) => renderTreeRow(c, depth + 1))} {isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
</> </Fragment>
); );
}; };

파일 보기

@ -1,45 +1,35 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react'; import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
fetchVesselAnalysis,
filterDarkVessels,
type VesselAnalysisItem,
} from '@/services/vesselAnalysisApi';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns'; import { getRiskIntent } from '@shared/constants/statusIntent';
import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ /* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; } interface Suspect {
id: string;
const GAP_FULL_BLOCK_MIN = 1440; mmsi: string;
const GAP_LONG_LOSS_MIN = 60; name: string;
const SPOOFING_THRESHOLD = 0.7; flag: string;
darkTier: string;
function derivePattern(item: VesselAnalysisItem): string { darkScore: number;
const { gapDurationMin } = item.algorithms.darkVessel; darkPatterns: string;
const { spoofingScore } = item.algorithms.gpsSpoofing; risk: number;
if (gapDurationMin > GAP_FULL_BLOCK_MIN) return 'AIS 완전차단'; gap: number;
if (spoofingScore > SPOOFING_THRESHOLD) return 'MMSI 변조 의심'; lastAIS: string;
if (gapDurationMin > GAP_LONG_LOSS_MIN) return '장기소실'; lat: number;
return '신호 간헐송출'; lng: number;
} [key: string]: unknown;
function deriveStatus(item: VesselAnalysisItem): string {
const { score } = item.algorithms.riskScore;
if (score >= 80) return '추적중';
if (score >= 50) return '감시중';
if (score >= 30) return '확인중';
return '정상';
} }
function deriveFlag(mmsi: string): string { function deriveFlag(mmsi: string): string {
@ -48,21 +38,32 @@ function deriveFlag(mmsi: string): string {
return '미상'; return '미상';
} }
function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect { const TIER_HEX: Record<string, string> = {
const risk = item.algorithms.riskScore.score; CRITICAL: '#ef4444',
const status = deriveStatus(item); HIGH: '#f97316',
WATCH: '#eab308',
NONE: '#6b7280',
};
function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
const feat = v.features ?? {};
const darkTier = (feat.dark_tier as string) ?? 'NONE';
const darkScore = (feat.dark_suspicion_score as number) ?? 0;
const patterns = (feat.dark_patterns as string[]) ?? [];
return { return {
id: `DV-${String(idx + 1).padStart(3, '0')}`, id: `DV-${String(idx + 1).padStart(3, '0')}`,
mmsi: item.mmsi, mmsi: v.mmsi,
name: item.classification.vesselType || item.mmsi, name: v.vesselType || v.mmsi,
flag: deriveFlag(item.mmsi), flag: deriveFlag(v.mmsi),
pattern: derivePattern(item), darkTier,
risk, darkScore,
lastAIS: formatDateTime(item.timestamp), darkPatterns: patterns.join(', ') || '-',
status, risk: v.riskScore ?? 0,
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-', gap: v.gapDurationMin ?? 0,
lat: 0, lastAIS: formatDateTime(v.analyzedAt),
lng: 0, lat: v.lat ?? 0,
lng: v.lon ?? 0,
}; };
} }
@ -70,25 +71,53 @@ export function DarkVesselDetection() {
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language); const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const [tierFilter, setTierFilter] = useState<string>('');
const cols: DataColumn<Suspect>[] = useMemo(() => [ const cols: DataColumn<Suspect>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> }, { key: 'id', label: 'ID', width: '70px',
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> }, { key: 'darkTier', label: '등급', width: '80px', sortable: true,
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> }, render: (v) => {
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> }, const tier = v as string;
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
} },
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
} },
{ key: 'name', label: '선박 유형', sortable: true,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px',
render: (v) => {
const mmsi = v as string;
return (
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{mmsi}
</button>
);
} },
{ key: 'flag', label: '국적', width: '50px' }, { key: 'flag', label: '국적', width: '50px' },
{ key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true,
render: (v) => {
const min = v as number;
return <span className="text-label font-mono text-[10px]">{min > 0 ? `${min}` : '-'}</span>;
} },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } }, render: (v) => {
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> }, const n = v as number;
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> }, } },
{ key: 'label', label: '라벨', width: '60px', align: 'center', { key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
render: v => { const l = v as string; return l === '-' ? <button type="button" className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> </button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } }, render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
], [tc, lang]); { key: 'lastAIS', label: '분석시각', width: '90px',
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang, navigate]);
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]); const [rawData, setRawData] = useState<VesselAnalysis[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -96,12 +125,10 @@ export function DarkVesselDetection() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const res = await fetchVesselAnalysis(); const res = await getDarkVessels({ hours: 1, size: 500 });
setServiceAvailable(res.serviceAvailable); setRawData(res.content);
setDarkItems(filterDarkVessels(res.items));
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -109,15 +136,36 @@ export function DarkVesselDetection() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const DATA: Suspect[] = useMemo( // 30초 자동 갱신 (깜박임 없음 — loading 상태 변경 없이 데이터만 교체)
() => darkItems.map((item, i) => mapItemToSuspect(item, i)), useEffect(() => {
[darkItems], const timer = setInterval(async () => {
); try {
const res = await getDarkVessels({ hours: 1, size: 500 });
setRawData(res.content);
} catch { /* silent */ }
}, 30_000);
return () => clearInterval(timer);
}, []);
const avgRisk = useMemo( const DATA: Suspect[] = useMemo(() => {
() => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0, let items = rawData.map((v, i) => mapToSuspect(v, i));
[DATA], if (tierFilter) {
); items = items.filter((d) => d.darkTier === tierFilter);
}
// 의심 점수 내림차순 정렬
return items.sort((a, b) => b.darkScore - a.darkScore);
}, [rawData, tierFilter]);
// KPI 카운트
const tierCounts = useMemo(() => {
const all = rawData.map((v) => ((v.features ?? {}).dark_tier as string) ?? 'NONE');
return {
total: all.length,
CRITICAL: all.filter((t) => t === 'CRITICAL').length,
HIGH: all.filter((t) => t === 'HIGH').length,
WATCH: all.filter((t) => t === 'WATCH').length,
};
}, [rawData]);
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
@ -125,21 +173,18 @@ export function DarkVesselDetection() {
...STATIC_LAYERS, ...STATIC_LAYERS,
createRadiusLayer( createRadiusLayer(
'dv-radius', 'dv-radius',
DATA.filter(d => d.risk > 80).map(d => ({ DATA.filter((d) => d.darkScore >= 70).map((d) => ({
lat: d.lat, lat: d.lat, lng: d.lng, radius: 10000,
lng: d.lng, color: TIER_HEX[d.darkTier] || '#ef4444',
radius: 10000,
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
})), })),
0.08, 0.08,
), ),
createMarkerLayer( createMarkerLayer(
'dv-markers', 'dv-markers',
DATA.map(d => ({ DATA.filter((d) => d.lat !== 0).map((d) => ({
lat: d.lat, lat: d.lat, lng: d.lng,
lng: d.lng, color: TIER_HEX[d.darkTier] || '#6b7280',
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', radius: d.darkScore >= 70 ? 1200 : 800,
radius: d.risk > 80 ? 1200 : 800,
label: `${d.id} ${d.name}`, label: `${d.id} ${d.name}`,
} as MarkerData)), } as MarkerData)),
), ),
@ -154,15 +199,20 @@ export function DarkVesselDetection() {
iconColor="text-red-400" iconColor="text-red-400"
title={t('darkVessel.title')} title={t('darkVessel.title')}
description={t('darkVessel.desc')} description={t('darkVessel.desc')}
actions={
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-hint" />
<Select size="sm" value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}
title="등급 필터" className="w-32">
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="WATCH">WATCH</option>
</Select>
</div>
}
/> />
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - Dark Vessel </span>
</div>
)}
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-red-400">: {error}</div>}
{loading && ( {loading && (
@ -171,49 +221,51 @@ export function DarkVesselDetection() {
</div> </div>
)} )}
{/* KPI — tier 기반 */}
<div className="flex gap-2"> <div className="flex gap-2">
{[ {[
{ l: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle }, { l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
{ l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff }, { l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
{ l: 'MMSI 변조', v: DATA.filter(d => d.pattern === 'MMSI 변조 의심').length, c: 'text-yellow-400', i: Radio }, { l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
{ l: `평균 위험도`, v: avgRisk, c: 'text-cyan-400', i: Tag }, { l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
].map(k => ( ].map((k) => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"> <div key={k.l}
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span> onClick={() => setTierFilter(k.filter)}
className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border cursor-pointer transition-colors ${
tierFilter === k.filter ? 'bg-card border-blue-500/30' : 'bg-card border-border hover:border-border'
}`}>
<AlertTriangle className={`w-4 h-4 ${k.c}`} />
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
<span className="text-[9px] text-hint">{k.l}</span>
</div> </div>
))} ))}
</div> </div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" /> <DataTable data={DATA} columns={cols} pageSize={10}
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
exportFilename="Dark_Vessel_탐지" />
{/* 탐지 위치 지도 */} {/* 탐지 위치 지도 */}
<Card> <Card>
<CardContent className="p-0 relative"> <CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.5, 127.5]} zoom={7} height={450} className="rounded-lg overflow-hidden" /> <BaseMap ref={mapRef} center={[36.5, 127.5]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
{/* 범례 */} {/* 범례 — tier 기반 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2"> <div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div> <div className="text-[9px] text-muted-foreground font-bold mb-1.5">Dark Tier</div>
<div className="space-y-1"> <div className="space-y-1">
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => { {(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => (
const meta = getDarkVesselPatternMeta(p); <div key={tier} className="flex items-center gap-1.5">
if (!meta) return null; <div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: TIER_HEX[tier] }} />
return ( <span className="text-[8px] text-muted-foreground">{tier}</span>
<div key={p} className="flex items-center gap-1.5"> </div>
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} /> ))}
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
</div> </div>
</div> </div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5"> <div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" /> <div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">{DATA.filter(d => d.risk > 80).length}</span> <span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[9px] text-hint"> Dark Vessel </span> <span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

파일 보기

@ -1,4 +1,5 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
@ -18,6 +19,8 @@ interface Record {
date: string; date: string;
zone: string; zone: string;
vessel: string; vessel: string;
mmsi: string;
eventId: number | null;
violation: string; violation: string;
action: string; action: string;
aiMatch: string; aiMatch: string;
@ -29,7 +32,8 @@ export function EnforcementHistory() {
const { t } = useTranslation('enforcement'); const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language); const lang = useSettingsStore((s) => s.language);
const { records, loading, error, load } = useEnforcementStore(); const navigate = useNavigate();
const { records, rawRecords, loading, error, load } = useEnforcementStore();
const cols: DataColumn<Record>[] = useMemo(() => [ const cols: DataColumn<Record>[] = useMemo(() => [
{ {
@ -55,9 +59,37 @@ export function EnforcementHistory() {
key: 'vessel', key: 'vessel',
label: '대상 선박', label: '대상 선박',
sortable: true, sortable: true,
render: (v) => ( render: (_v, row) => {
<span className="text-cyan-400 font-medium">{v as string}</span> const mmsi = row.mmsi;
), const vessel = row.vessel as string;
if (mmsi && mmsi !== '-') {
return (
<button type="button"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-medium"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{vessel}
</button>
);
}
return <span className="text-cyan-400 font-medium">{vessel}</span>;
},
},
{
key: 'eventId',
label: '이벤트',
width: '70px',
render: (_v, row) => {
const eid = row.eventId;
if (!eid) return <span className="text-hint">-</span>;
return (
<button type="button"
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/events?id=${eid}`); }}
title={`이벤트 #${eid}`}>
#{eid}
</button>
);
},
}, },
{ {
key: 'violation', key: 'violation',
@ -119,7 +151,11 @@ export function EnforcementHistory() {
load(); load();
}, [load]); }, [load]);
const DATA: Record[] = records as Record[]; const DATA: Record[] = records.map((r, idx) => ({
...r,
mmsi: rawRecords[idx]?.vesselMmsi ?? '-',
eventId: rawRecords[idx]?.eventId ?? null,
})) as Record[];
return ( return (
<PageContainer> <PageContainer>

파일 보기

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
@ -9,8 +10,11 @@ import { FileUpload } from '@shared/components/common/FileUpload';
import { import {
AlertTriangle, Eye, Anchor, Radar, Crosshair, AlertTriangle, Eye, Anchor, Radar, Crosshair,
Filter, Upload, X, Loader2, Filter, Upload, X, Loader2,
CheckCircle, Ship, Shield, Ban,
} from 'lucide-react'; } from 'lucide-react';
import { useEventStore } from '@stores/eventStore'; import { useEventStore } from '@stores/eventStore';
import { ackEvent, updateEventStatus } from '@/services/event';
import { createEnforcementRecord } from '@/services/enforcement';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses'; import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
@ -27,6 +31,7 @@ type AlertLevel = AlertLevelType;
interface EventRow { interface EventRow {
id: string; id: string;
_eventId: number;
time: string; time: string;
level: AlertLevel; level: AlertLevel;
type: string; type: string;
@ -45,14 +50,56 @@ export function EventList() {
const { t } = useTranslation('enforcement'); const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language); const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const { const {
events: storeEvents, events: storeEvents,
rawEvents,
stats, stats,
loading, loading,
error, error,
load, load,
silentRefresh,
loadStats, loadStats,
} = useEventStore(); } = useEventStore();
const [actionLoading, setActionLoading] = useState<number | null>(null);
const handleAck = useCallback(async (eventId: number) => {
setActionLoading(eventId);
try {
await ackEvent(eventId);
load({ level: '' });
} finally {
setActionLoading(null);
}
}, [load]);
const handleFalsePositive = useCallback(async (eventId: number) => {
setActionLoading(eventId);
try {
await updateEventStatus(eventId, 'FALSE_POSITIVE', '오탐 처리');
load({ level: '' });
} finally {
setActionLoading(null);
}
}, [load]);
const handleCreateEnforcement = useCallback(async (row: EventRow) => {
setActionLoading(row._eventId);
try {
await createEnforcementRecord({
eventId: row._eventId,
enforcedAt: new Date().toISOString(),
vesselMmsi: row.mmsi !== '-' ? row.mmsi : undefined,
vesselName: row.vesselName !== '-' ? row.vesselName : undefined,
zoneCode: row.area !== '-' ? row.area : undefined,
violationType: row.type,
action: 'PATROL_DISPATCH',
});
load({ level: '' });
} finally {
setActionLoading(null);
}
}, [load]);
const columns: DataColumn<EventRow>[] = useMemo(() => [ const columns: DataColumn<EventRow>[] = useMemo(() => [
{ {
@ -83,12 +130,24 @@ export function EventList() {
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>, render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
}, },
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px', { key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>, render: (_val, row) => {
const mmsi = row.mmsi;
if (!mmsi || mmsi === '-') return <span className="text-hint">-</span>;
return (
<button
type="button"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}
>
{mmsi}
</button>
);
},
}, },
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true }, { key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' }, { key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
{ {
key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true, key: 'status', label: '처리상태', minWidth: '70px', maxWidth: '100px', sortable: true,
render: (val) => { render: (val) => {
const s = val as string; const s = val as string;
return ( return (
@ -98,8 +157,46 @@ export function EventList() {
); );
}, },
}, },
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' }, {
], [tc, lang]); key: '_eventId', label: '액션', minWidth: '120px', maxWidth: '180px',
render: (_val, row) => {
const eid = row._eventId;
const isNew = row.status === 'NEW';
const isActionable = row.status !== 'RESOLVED' && row.status !== 'FALSE_POSITIVE';
const busy = actionLoading === eid;
return (
<div className="flex items-center gap-1">
{isNew && (
<button type="button" aria-label="확인" title="확인(ACK)"
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" />
</button>
)}
<button type="button" aria-label="선박 상세" title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
<Ship className="w-3.5 h-3.5" />
</button>
{isActionable && (
<>
<button type="button" aria-label="단속 등록" title="단속 등록"
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" />
</button>
<button type="button" aria-label="오탐 처리" title="오탐 처리"
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
);
},
},
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]);
const [levelFilter, setLevelFilter] = useState<string>(''); const [levelFilter, setLevelFilter] = useState<string>('');
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
@ -114,9 +211,20 @@ export function EventList() {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
// store events -> EventRow 변환 // 30초 자동 갱신 (깜박임 없음 — silentRefresh 사용)
const EVENTS: EventRow[] = storeEvents.map((e) => ({ useEffect(() => {
const params = levelFilter ? { level: levelFilter } : undefined;
const timer = setInterval(() => {
silentRefresh(params);
loadStats();
}, 30_000);
return () => clearInterval(timer);
}, [levelFilter, silentRefresh, loadStats]);
// store events -> EventRow 변환 (rawEvents에서 numeric id 참조)
const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({
id: e.id, id: e.id,
_eventId: rawEvents[idx]?.id ?? 0,
time: e.time, time: e.time,
level: e.level as AlertLevel, level: e.level as AlertLevel,
type: e.type, type: e.type,

파일 보기

@ -9,6 +9,8 @@ import { useAuth } from '@/app/auth/AuthContext';
import { import {
fetchReviewList, fetchReviewList,
reviewParent, reviewParent,
createLabelSession,
excludeForGroup,
type ParentResolution, type ParentResolution,
} from '@/services/parentInferenceApi'; } from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
@ -70,6 +72,28 @@ export function ParentReview() {
selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined, selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined,
comment: `${action} via UI`, comment: `${action} via UI`,
}); });
// CONFIRM → LabelSession 자동 생성 (학습 데이터 수집 시작)
if (action === 'CONFIRM') {
const mmsi = selectedMmsi || item.selectedParentMmsi;
if (mmsi) {
await createLabelSession(item.groupKey, item.subClusterId, {
labelParentMmsi: mmsi,
}).catch(() => { /* LabelSession 실패는 무시 — 리뷰 자체는 성공 */ });
}
}
// REJECT → Exclusion 자동 등록 (잘못된 후보 재추론 방지)
if (action === 'REJECT') {
const mmsi = item.selectedParentMmsi;
if (mmsi) {
await excludeForGroup(item.groupKey, item.subClusterId, {
excludedMmsi: mmsi,
reason: '운영자 거부',
}).catch(() => { /* Exclusion 실패는 무시 */ });
}
}
await load(); await load();
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown'; const msg = e instanceof Error ? e.message : 'unknown';

파일 보기

@ -6,10 +6,14 @@ import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent'; import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react'; import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement'; import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
import { getEvents, type PredictionEvent } from '@/services/event';
import { formatDateTime } from '@shared/utils/dateFormat';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */ /* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
@ -47,18 +51,25 @@ const cols: DataColumn<Plan>[] = [
export function EnforcementPlan() { export function EnforcementPlan() {
const { t } = useTranslation('enforcement'); const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [plans, setPlans] = useState<Plan[]>([]); const [plans, setPlans] = useState<Plan[]>([]);
const [criticalEvents, setCriticalEvents] = useState<PredictionEvent[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
getEnforcementPlans({ size: 100 }) Promise.all([
.then((res) => { getEnforcementPlans({ size: 100 }),
getEvents({ level: 'CRITICAL', status: 'NEW', size: 20 }).catch(() => null),
])
.then(([planRes, evtRes]) => {
if (!cancelled) { if (!cancelled) {
setPlans(res.content.map(toPlan)); setPlans(planRes.content.map(toPlan));
setCriticalEvents(evtRes?.content ?? []);
setLoading(false); setLoading(false);
} }
}) })
@ -154,6 +165,31 @@ export function EnforcementPlan() {
</div> </div>
))} ))}
</div> </div>
{/* 미배정 CRITICAL 이벤트 */}
{criticalEvents.length > 0 && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-[12px] font-bold text-heading"> CRITICAL </span>
<Badge intent="critical" size="xs">{criticalEvents.length}</Badge>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{criticalEvents.map((evt) => (
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
{getAlertLevelLabel(evt.level, tc, lang)}
</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div> <div className="text-[12px] font-bold text-heading mb-3"> </div>

파일 보기

@ -6,16 +6,15 @@ import {
Search, Search,
Ship, AlertTriangle, Radar, MapPin, Printer, Ship, AlertTriangle, Radar, MapPin, Printer,
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain, Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
Loader2, WifiOff, ShieldAlert, Loader2, ShieldAlert, Shield, EyeOff, FileText,
} from 'lucide-react'; } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
import {
fetchVesselAnalysis,
type VesselAnalysisItem,
} from '@/services/vesselAnalysisApi';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { getEvents, type PredictionEvent } from '@/services/event'; import { getEvents, type PredictionEvent } from '@/services/event';
import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
import { getEnforcementRecords, type EnforcementRecord } from '@/services/enforcement';
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -57,27 +56,66 @@ const RIGHT_TOOLS = [
{ icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' }, { icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' },
]; ];
// ─── 24h AIS 수신 막대 ───────────────
function AisTimeline({ history }: { history: VesselAnalysis[] }) {
// 최근 24시간을 1시간 단위 슬롯으로 분할
const now = Date.now();
const slots = Array.from({ length: 24 }, (_, i) => {
const slotStart = now - (24 - i) * 3600_000;
const slotEnd = slotStart + 3600_000;
const hasData = history.some((h) => {
const t = new Date(h.analyzedAt).getTime();
return t >= slotStart && t < slotEnd;
});
return { hour: new Date(slotStart).getHours(), hasData };
});
const received = slots.filter((s) => s.hasData).length;
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-hint">24h AIS </span>
<span className="text-[10px] text-label font-mono">{received}/24h ({Math.round(received / 24 * 100)}%)</span>
</div>
<div className="flex gap-px h-3">
{slots.map((s, i) => (
<div
key={i}
className={`flex-1 rounded-sm ${s.hasData ? 'bg-green-500' : 'bg-red-500/40'}`}
title={`${String(s.hour).padStart(2, '0')}시 — ${s.hasData ? '수신' : '소실'}`}
/>
))}
</div>
<div className="flex justify-between mt-0.5">
<span className="text-[7px] text-hint">-24h</span>
<span className="text-[7px] text-hint"></span>
</div>
</div>
);
}
// ─── 메인 컴포넌트 ──────────────────── // ─── 메인 컴포넌트 ────────────────────
export function VesselDetail() { export function VesselDetail() {
const { id: mmsiParam } = useParams<{ id: string }>(); const { id: mmsiParam } = useParams<{ id: string }>();
// 데이터 상태 // 데이터 상태
const [vessel, setVessel] = useState<VesselAnalysisItem | null>(null); const [analysis, setAnalysis] = useState<VesselAnalysis | null>(null);
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const [permit, setPermit] = useState<VesselPermitData | null>(null); const [permit, setPermit] = useState<VesselPermitData | null>(null);
const [events, setEvents] = useState<PredictionEvent[]>([]); const [events, setEvents] = useState<PredictionEvent[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true); const [enforcements, setEnforcements] = useState<EnforcementRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 검색 상태 (검색 패널용) // 검색 상태
const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? ''); const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? '');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
// 데이터 로드 // 데이터 로드 — prediction 직접 API
useEffect(() => { useEffect(() => {
if (!mmsiParam) { if (!mmsiParam) {
setLoading(false); setLoading(false);
@ -92,27 +130,21 @@ export function VesselDetail() {
setError(null); setError(null);
try { try {
const [analysisRes, permitRes, eventsRes] = await Promise.all([ const [analysisRes, historyRes, permitRes, eventsRes, enfRes] = await Promise.all([
fetchVesselAnalysis().catch(() => null), getAnalysisLatest(mmsiParam).catch(() => null),
getAnalysisHistory(mmsiParam, 24).catch(() => []),
fetchVesselPermit(mmsiParam), fetchVesselPermit(mmsiParam),
getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null), getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
getEnforcementRecords({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
]); ]);
if (cancelled) return; if (cancelled) return;
if (!analysisRes) { setAnalysis(analysisRes);
setServiceAvailable(false); setHistory(historyRes);
setPermit(permitRes);
setEvents(eventsRes?.content ?? []);
setLoading(false);
return;
}
setServiceAvailable(analysisRes.serviceAvailable);
const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null;
setVessel(found);
setPermit(permitRes); setPermit(permitRes);
setEvents(eventsRes?.content ?? []); setEvents(eventsRes?.content ?? []);
setEnforcements(enfRes?.content ?? []);
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
setError(err instanceof Error ? err.message : '데이터 로드 실패'); setError(err instanceof Error ? err.message : '데이터 로드 실패');
@ -127,24 +159,17 @@ export function VesselDetail() {
}, [mmsiParam]); }, [mmsiParam]);
// 지도 레이어 // 지도 레이어
const buildLayers = useCallback(() => { const buildLayers = useCallback(() => [
const layers = [ ...STATIC_LAYERS,
...STATIC_LAYERS, createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, })), 80000, 0.05),
})), 80000, 0.05), ...DEPTH_CONTOURS.map((contour, i) =>
...DEPTH_CONTOURS.map((contour, i) => createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], { color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4], })
}) ),
), ], []);
];
// 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가
// vessel-analysis에는 좌표가 없으므로 마커 생략
return layers;
}, []);
useMapLayers(mapRef, buildLayers, []); useMapLayers(mapRef, buildLayers, []);
@ -152,11 +177,20 @@ export function VesselDetail() {
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language); const lang = useSettingsStore((s) => s.language);
// 위험도 점수 바 // 위험도
const riskScore = vessel?.algorithms.riskScore.score ?? 0; const riskScore = analysis?.riskScore ?? 0;
const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel; const riskLevel = (analysis?.riskLevel ?? 'LOW') as AlertLevel;
const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW; const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW;
// features 추출
const features = analysis?.features ?? {};
const darkTier = features.dark_tier as string | undefined;
const darkScore = features.dark_suspicion_score as number | undefined;
const darkPatterns = features.dark_patterns as string[] | undefined;
const darkHistory7d = features.dark_history_7d as number | undefined;
const transshipTier = features.transship_tier as string | undefined;
const transshipScore = features.transship_score as number | undefined;
return ( return (
<PageContainer fullBleed className="flex h-[calc(100vh-7.5rem)] gap-0"> <PageContainer fullBleed className="flex h-[calc(100vh-7.5rem)] gap-0">
@ -201,16 +235,6 @@ export function VesselDetail() {
</div> </div>
)} )}
{!serviceAvailable && !loading && !error && (
<div className="p-3 mx-3 mt-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
</div>
<p className="text-[10px] text-hint mt-1">iran .</p>
</div>
)}
{/* 선박 정보 */} {/* 선박 정보 */}
{!loading && !error && ( {!loading && !error && (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
@ -223,17 +247,17 @@ export function VesselDetail() {
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]"> <div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{[ {[
['MMSI', mmsiParam ?? '-'], ['MMSI', mmsiParam ?? '-'],
['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'], ['선박 유형', analysis?.vesselType ?? permit?.vesselType ?? '-'],
['국적', permit?.flagCountry ?? '-'], ['국적', permit?.flagCountry ?? '-'],
['선명', permit?.vesselName ?? '-'], ['선명', permit?.vesselName ?? '-'],
['선명(중문)', permit?.vesselNameCn ?? '-'], ['선명(중문)', permit?.vesselNameCn ?? '-'],
['톤수', permit?.tonnage != null ? `${permit.tonnage}` : '-'], ['톤수', permit?.tonnage != null ? `${permit.tonnage}` : '-'],
['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'], ['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'],
['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'], ['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'],
['구역', vessel?.algorithms.location.zone ?? '-'], ['구역', analysis?.zoneCode ?? '-'],
['기선거리', vessel?.algorithms.location.distToBaselineNm != null ['기선거리', analysis?.distToBaselineNm != null
? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'], ? `${Number(analysis.distToBaselineNm).toFixed(1)}nm` : '-'],
['시즌', vessel?.classification.season ?? '-'], ['시즌', analysis?.season ?? '-'],
].map(([k, v], i) => ( ].map(([k, v], i) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}> <div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span> <span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
@ -268,8 +292,8 @@ export function VesselDetail() {
</div> </div>
)} )}
{/* AI 분석 결과 */} {/* AI 분석 결과 — prediction 직접 데이터 */}
{vessel && ( {analysis && (
<div className="p-3 border-b border-border"> <div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Brain className="w-4 h-4 text-purple-400" /> <Brain className="w-4 h-4 text-purple-400" />
@ -286,14 +310,14 @@ export function VesselDetail() {
</div> </div>
<div className="flex items-baseline gap-1 mb-1"> <div className="flex items-baseline gap-1 mb-1">
<span className={`text-xl font-bold ${riskMeta.classes.text}`}> <span className={`text-xl font-bold ${riskMeta.classes.text}`}>
{Math.round(riskScore * 100)} {riskScore}
</span> </span>
<span className="text-[10px] text-hint">/100</span> <span className="text-[10px] text-hint">/100</span>
</div> </div>
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden"> <div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
<div <div
className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all" className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
style={{ width: `${riskScore * 100}%` }} style={{ width: `${Math.min(riskScore, 100)}%` }}
/> />
</div> </div>
</div> </div>
@ -301,21 +325,17 @@ export function VesselDetail() {
{/* 알고리즘 상세 */} {/* 알고리즘 상세 */}
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]"> <div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{[ {[
['활동 상태', vessel.algorithms.activity.state], ['활동 상태', analysis.activityState ?? '-'],
['UCAF 점수', vessel.algorithms.activity.ucafScore.toFixed(2)], ['다크베셀', analysis.isDark ? '예 (의심)' : '아니오'],
['UCFT 점수', vessel.algorithms.activity.ucftScore.toFixed(2)], ['AIS 공백', analysis.gapDurationMin != null && analysis.gapDurationMin > 0
['다크베셀', vessel.algorithms.darkVessel.isDark ? '예 (의심)' : '아니오'], ? `${analysis.gapDurationMin}` : '-'],
['AIS 공백', vessel.algorithms.darkVessel.gapDurationMin > 0 ['스푸핑 점수', analysis.spoofingScore != null ? Number(analysis.spoofingScore).toFixed(2) : '-'],
? `${vessel.algorithms.darkVessel.gapDurationMin}` : '-'], ['속도 점프', analysis.speedJumpCount != null ? `${analysis.speedJumpCount}` : '-'],
['스푸핑 점수', vessel.algorithms.gpsSpoofing.spoofingScore.toFixed(2)], ['선단 역할', analysis.fleetRole ?? '-'],
['BD09 오프셋', `${vessel.algorithms.gpsSpoofing.bd09OffsetM.toFixed(0)}m`], ['환적 의심', analysis.transshipSuspect ? '예' : '아니오'],
['속도 점프', `${vessel.algorithms.gpsSpoofing.speedJumpCount}`], ['환적 상대', analysis.transshipPairMmsi || '-'],
['클러스터', `#${vessel.algorithms.cluster.clusterId} (${vessel.algorithms.cluster.clusterSize}척)`], ['환적 시간', analysis.transshipDurationMin != null && analysis.transshipDurationMin > 0
['선단 역할', vessel.algorithms.fleetRole.role], ? `${analysis.transshipDurationMin}` : '-'],
['환적 의심', vessel.algorithms.transship.isSuspect ? '예' : '아니오'],
['환적 상대', vessel.algorithms.transship.pairMmsi || '-'],
['환적 시간', vessel.algorithms.transship.durationMin > 0
? `${vessel.algorithms.transship.durationMin}` : '-'],
].map(([k, v], i) => ( ].map(([k, v], i) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}> <div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span> <span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
@ -329,8 +349,68 @@ export function VesselDetail() {
</div> </div>
)} )}
{/* Dark 패턴 시각화 — features 기반 */}
{analysis?.isDark && darkTier && (
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<EyeOff className="w-4 h-4 text-red-400" />
<span className="text-[11px] font-bold text-heading">Dark Vessel </span>
</div>
<div className="space-y-2">
{/* Dark tier + score */}
<div className="flex items-center gap-2">
<Badge intent={getRiskIntent(darkScore ?? 0)} size="sm">{darkTier}</Badge>
<span className="text-[10px] text-label font-mono">{darkScore ?? 0}</span>
{darkHistory7d != null && darkHistory7d > 0 && (
<span className="text-[9px] text-red-400">7 {darkHistory7d} </span>
)}
</div>
{/* 의심 점수 바 */}
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
<div
className="h-1.5 rounded-full transition-all"
style={{
width: `${Math.min(darkScore ?? 0, 100)}%`,
backgroundColor: (darkScore ?? 0) >= 70 ? '#ef4444' : (darkScore ?? 0) >= 50 ? '#f97316' : '#eab308',
}}
/>
</div>
{/* Dark 패턴 태그 */}
{darkPatterns && darkPatterns.length > 0 && (
<div className="flex flex-wrap gap-1">
{darkPatterns.map((p) => (
<Badge key={p} intent="muted" size="xs">{p}</Badge>
))}
</div>
)}
</div>
</div>
)}
{/* 환적 분석 — features 기반 */}
{analysis?.transshipSuspect && transshipTier && (
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4 text-orange-400" />
<span className="text-[11px] font-bold text-heading"> </span>
</div>
<div className="flex items-center gap-2">
<Badge intent={getRiskIntent(transshipScore ?? 0)} size="sm">{transshipTier}</Badge>
<span className="text-[10px] text-label font-mono">{transshipScore ?? 0}</span>
<span className="text-[9px] text-hint">: {analysis.transshipPairMmsi ?? '-'}</span>
</div>
</div>
)}
{/* 24h AIS 수신 이력 */}
{history.length > 0 && (
<div className="p-3 border-b border-border">
<AisTimeline history={history} />
</div>
)}
{/* 관련 이벤트 이력 */} {/* 관련 이벤트 이력 */}
<div className="p-3"> <div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-400" /> <AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-[11px] font-bold text-heading"> </span> <span className="text-[11px] font-bold text-heading"> </span>
@ -340,27 +420,53 @@ export function VesselDetail() {
<div className="text-[10px] text-hint text-center py-4"> .</div> <div className="text-[10px] text-hint text-center py-4"> .</div>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{events.map((evt) => { {events.map((evt) => (
return ( <div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2"> <div className="flex items-center gap-2 mb-0.5">
<div className="flex items-center gap-2 mb-0.5"> <Badge intent={getAlertLevelIntent(evt.level)} size="xs">
<Badge intent={getAlertLevelIntent(evt.level)} size="xs"> {getAlertLevelLabel(evt.level, tc, lang)}
{getAlertLevelLabel(evt.level, tc, lang)} </Badge>
</Badge> <span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span> <Badge intent="muted" size="xs" className="px-1.5 py-0">
<Badge intent="muted" size="xs" className="px-1.5 py-0"> {evt.status}
{evt.status} </Badge>
</Badge>
</div>
<div className="text-[9px] text-hint">
{evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''}
</div>
{evt.detail && (
<div className="text-[9px] text-muted-foreground mt-0.5 truncate">{evt.detail}</div>
)}
</div> </div>
); <div className="text-[9px] text-hint">
})} {formatDateTime(evt.occurredAt)} {evt.areaName ? `| ${evt.areaName}` : ''}
</div>
</div>
))}
</div>
)}
</div>
{/* 단속 이력 */}
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-green-400" />
<span className="text-[11px] font-bold text-heading"> </span>
<span className="text-[9px] text-hint ml-auto">{enforcements.length}</span>
</div>
{enforcements.length === 0 ? (
<div className="text-[10px] text-hint text-center py-4"> .</div>
) : (
<div className="space-y-1.5">
{enforcements.map((enf) => (
<div key={enf.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
<div className="flex items-center gap-2 mb-0.5">
<Badge intent="info" size="xs">{enf.enfUid}</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">
{enf.violationType ?? '단속'}
</span>
<Badge intent={enf.result === 'PUNISHED' ? 'critical' : 'muted'} size="xs">
{enf.result ?? '-'}
</Badge>
</div>
<div className="text-[9px] text-hint">
{formatDateTime(enf.enforcedAt)} {enf.areaName ? `| ${enf.areaName}` : ''}
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@ -376,7 +482,7 @@ export function VesselDetail() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Ship className="w-4 h-4 text-blue-400" /> <Ship className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span> <span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
{vessel && ( {analysis && (
<Badge intent={riskMeta.intent} size="sm"> <Badge intent={riskMeta.intent} size="sm">
: {getAlertLevelLabel(riskLevel, tc, lang)} : {getAlertLevelLabel(riskLevel, tc, lang)}
</Badge> </Badge>
@ -387,8 +493,11 @@ export function VesselDetail() {
<BaseMap <BaseMap
ref={mapRef} ref={mapRef}
center={[34.5, 126.5]} center={[
zoom={7} analysis?.lat ?? 34.5,
analysis?.lon ?? 126.5,
]}
zoom={analysis?.lat ? 9 : 7}
height="100%" height="100%"
/> />
@ -397,15 +506,15 @@ export function VesselDetail() {
<span className="flex items-center gap-1 text-[8px]"> <span className="flex items-center gap-1 text-[8px]">
<MapPin className="w-2.5 h-2.5 text-green-400" /> <MapPin className="w-2.5 h-2.5 text-green-400" />
<span className="text-hint"></span> <span className="text-hint"></span>
<span className="text-green-400 font-mono font-bold">34.5000</span> <span className="text-green-400 font-mono font-bold">{analysis?.lat?.toFixed(4) ?? '-'}</span>
</span> </span>
<span className="flex items-center gap-1 text-[8px]"> <span className="flex items-center gap-1 text-[8px]">
<MapPin className="w-2.5 h-2.5 text-green-400" /> <MapPin className="w-2.5 h-2.5 text-green-400" />
<span className="text-hint"></span> <span className="text-hint"></span>
<span className="text-green-400 font-mono font-bold">126.5000</span> <span className="text-green-400 font-mono font-bold">{analysis?.lon?.toFixed(4) ?? '-'}</span>
</span> </span>
<span className="text-[8px]"> <span className="text-[8px]">
<span className="text-blue-400 font-bold">UTC</span> <span className="text-blue-400 font-bold">KST</span>
<span className="text-label font-mono ml-1">{formatDateTime(new Date())}</span> <span className="text-label font-mono ml-1">{formatDateTime(new Date())}</span>
</span> </span>
</div> </div>

파일 보기

@ -153,6 +153,50 @@
"status": "implemented", "status": "implemented",
"file": "frontend/src/features/parent-inference/LabelSession.tsx" "file": "frontend/src/features/parent-inference/LabelSession.tsx"
}, },
{
"id": "ui.map_control",
"label": "해역 관리",
"shortDescription": "해역 구역 설정/관리 화면",
"stage": "UI",
"menu": "감시",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/surveillance/MapControl.tsx"
},
{
"id": "ui.risk_map",
"label": "위험도 지도",
"shortDescription": "해역별 위험도 히트맵",
"stage": "UI",
"menu": "위험평가",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/risk-assessment/RiskMap.tsx"
},
{
"id": "ui.patrol_route",
"label": "순찰경로 추천",
"shortDescription": "AI 기반 순찰 경로 최적화",
"stage": "UI",
"menu": "순찰",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/patrol/PatrolRoute.tsx"
},
{
"id": "ui.fleet_optimization",
"label": "다함정 최적화",
"shortDescription": "다수 함정 배치 최적화",
"stage": "UI",
"menu": "순찰",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/patrol/FleetOptimization.tsx"
},
{ {
"id": "ui.statistics", "id": "ui.statistics",
"label": "통계", "label": "통계",
@ -164,6 +208,28 @@
"status": "implemented", "status": "implemented",
"file": "frontend/src/features/statistics/Statistics.tsx" "file": "frontend/src/features/statistics/Statistics.tsx"
}, },
{
"id": "ui.report_management",
"label": "보고서 관리",
"shortDescription": "보고서 생성/조회",
"stage": "UI",
"menu": "통계",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/statistics/ReportManagement.tsx"
},
{
"id": "ui.external_service",
"label": "외부 서비스",
"shortDescription": "외부 연동 서비스 설정",
"stage": "UI",
"menu": "통계",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/statistics/ExternalService.tsx"
},
{ {
"id": "ui.ai_alert", "id": "ui.ai_alert",
"label": "현장 AI 경보", "label": "현장 AI 경보",
@ -185,5 +251,82 @@
"trigger": "on_demand", "trigger": "on_demand",
"status": "implemented", "status": "implemented",
"file": "frontend/src/features/ai-operations/AIAssistant.tsx" "file": "frontend/src/features/ai-operations/AIAssistant.tsx"
},
{
"id": "ui.ai_model",
"label": "AI 모델관리",
"shortDescription": "AI 모델 배포/모니터링",
"stage": "UI",
"menu": "AI",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/ai-operations/AIModelManagement.tsx"
},
{
"id": "ui.mlops",
"label": "MLOps",
"shortDescription": "ML 파이프라인 운영",
"stage": "UI",
"menu": "AI",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/ai-operations/MLOpsPage.tsx"
},
{
"id": "ui.llm_ops",
"label": "LLM 운영",
"shortDescription": "LLM 모델 운영 관리",
"stage": "UI",
"menu": "AI",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/ai-operations/LLMOpsPage.tsx"
},
{
"id": "ui.mobile_service",
"label": "모바일 서비스",
"shortDescription": "현장 모바일 서비스",
"stage": "UI",
"menu": "현장",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/field-ops/MobileService.tsx"
},
{
"id": "ui.ship_agent",
"label": "함정 Agent",
"shortDescription": "함정 단말 에이전트",
"stage": "UI",
"menu": "현장",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/field-ops/ShipAgent.tsx"
},
{
"id": "ui.admin_panel",
"label": "시스템 관리",
"shortDescription": "사용자/역할/권한 관리",
"stage": "UI",
"menu": "관리",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/admin/AdminPanel.tsx"
},
{
"id": "ui.permissions",
"label": "권한 관리",
"shortDescription": "RBAC 트리 권한 매트릭스",
"stage": "UI",
"menu": "관리",
"kind": "ui",
"trigger": "on_demand",
"status": "implemented",
"file": "frontend/src/features/admin/AccessControl.tsx"
} }
] ]

파일 보기

@ -3,6 +3,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"monitoring": "Alert Monitor", "monitoring": "Alert Monitor",
"riskMap": "Risk Map", "riskMap": "Risk Map",
"mapControl": "Map Control",
"enforcementPlan": "Enforcement Plan", "enforcementPlan": "Enforcement Plan",
"darkVessel": "Dark Vessel", "darkVessel": "Dark Vessel",
"gearDetection": "Gear Detection", "gearDetection": "Gear Detection",
@ -132,6 +133,23 @@
"INTERMITTENT": "Intermittent", "INTERMITTENT": "Intermittent",
"SPEED_ANOMALY": "Speed Anomaly" "SPEED_ANOMALY": "Speed Anomaly"
}, },
"darkTier": {
"CRITICAL": "Intentional Loss (Critical)",
"HIGH": "Suspicious Loss",
"WATCH": "Under Watch",
"NONE": "Normal"
},
"transshipTier": {
"CRITICAL": "Confirmed Transship",
"HIGH": "Suspected Transship",
"WATCH": "Under Watch"
},
"adminSubGroup": {
"aiPlatform": "AI Platform",
"systemOps": "System Operations",
"userMgmt": "User Management",
"auditSecurity": "Audit & Security"
},
"userAccountStatus": { "userAccountStatus": {
"ACTIVE": "Active", "ACTIVE": "Active",
"PENDING": "Pending", "PENDING": "Pending",

파일 보기

@ -3,6 +3,7 @@
"dashboard": "종합 상황판", "dashboard": "종합 상황판",
"monitoring": "경보 현황판", "monitoring": "경보 현황판",
"riskMap": "위험도 지도", "riskMap": "위험도 지도",
"mapControl": "해역 관리",
"enforcementPlan": "단속 계획", "enforcementPlan": "단속 계획",
"darkVessel": "다크베셀 탐지", "darkVessel": "다크베셀 탐지",
"gearDetection": "어구 탐지", "gearDetection": "어구 탐지",
@ -132,6 +133,23 @@
"INTERMITTENT": "신호 간헐송출", "INTERMITTENT": "신호 간헐송출",
"SPEED_ANOMALY": "속도 이상" "SPEED_ANOMALY": "속도 이상"
}, },
"darkTier": {
"CRITICAL": "고의 소실 (위험)",
"HIGH": "의심 소실",
"WATCH": "관찰 대상",
"NONE": "정상"
},
"transshipTier": {
"CRITICAL": "환적 확실",
"HIGH": "환적 의심",
"WATCH": "관찰 대상"
},
"adminSubGroup": {
"aiPlatform": "AI 플랫폼",
"systemOps": "시스템 운영",
"userMgmt": "사용자 관리",
"auditSecurity": "감사·보안"
},
"userAccountStatus": { "userAccountStatus": {
"ACTIVE": "활성", "ACTIVE": "활성",
"PENDING": "승인 대기", "PENDING": "승인 대기",

파일 보기

@ -61,6 +61,12 @@ export interface PermTreeNode {
rsrcLevel: number; rsrcLevel: number;
sortOrd: number; sortOrd: number;
useYn: string; useYn: string;
/** V021: 메뉴 SSOT */
labelKey: string | null;
urlPath: string | null;
navSort: number;
/** V022: DB i18n JSONB {"ko":"...", "en":"..."} */
labels: Record<string, string>;
} }
export interface RoleWithPermissions { export interface RoleWithPermissions {

파일 보기

@ -0,0 +1,113 @@
/**
* vessel_analysis_results API .
* /api/analysis/* .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface VesselAnalysis {
id: number;
mmsi: string;
analyzedAt: string;
vesselType: string | null;
confidence: number | null;
fishingPct: number | null;
season: string | null;
lat: number | null;
lon: number | null;
zoneCode: string | null;
distToBaselineNm: number | null;
activityState: string | null;
isDark: boolean | null;
gapDurationMin: number | null;
darkPattern: string | null;
spoofingScore: number | null;
speedJumpCount: number | null;
transshipSuspect: boolean | null;
transshipPairMmsi: string | null;
transshipDurationMin: number | null;
fleetClusterId: number | null;
fleetRole: string | null;
fleetIsLeader: boolean | null;
riskScore: number | null;
riskLevel: string | null;
gearCode: string | null;
gearJudgment: string | null;
permitStatus: string | null;
features: Record<string, unknown> | null;
}
export interface AnalysisPageResponse {
content: VesselAnalysis[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
/** 분석 결과 목록 조회 */
export async function getAnalysisVessels(params?: {
mmsi?: string;
zoneCode?: string;
riskLevel?: string;
isDark?: boolean;
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse> {
const query = new URLSearchParams();
if (params?.mmsi) query.set('mmsi', params.mmsi);
if (params?.zoneCode) query.set('zoneCode', params.zoneCode);
if (params?.riskLevel) query.set('riskLevel', params.riskLevel);
if (params?.isDark != null) query.set('isDark', String(params.isDark));
query.set('hours', String(params?.hours ?? 1));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50));
const res = await fetch(`${API_BASE}/analysis/vessels?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 특정 선박 최신 분석 결과 */
export async function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 특정 선박 분석 이력 */
export async function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 다크 베셀 목록 */
export async function getDarkVessels(params?: {
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse> {
const query = new URLSearchParams();
query.set('hours', String(params?.hours ?? 1));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50));
const res = await fetch(`${API_BASE}/analysis/dark?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 환적 의심 목록 */
export async function getTransshipSuspects(params?: {
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse> {
const query = new URLSearchParams();
query.set('hours', String(params?.hours ?? 1));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50));
const res = await fetch(`${API_BASE}/analysis/transship?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}

파일 보기

@ -6,6 +6,23 @@
const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface MenuConfigItem {
menuCd: string;
parentMenuCd: string | null;
menuType: 'ITEM' | 'GROUP' | 'DIVIDER';
urlPath: string | null;
rsrcCd: string | null;
componentKey: string | null;
icon: string | null;
labelKey: string | null;
dividerLabel: string | null;
menuLevel: number;
sortOrd: number;
useYn: string;
/** DB i18n SSOT: {"ko":"종합 상황판","en":"Dashboard"} */
labels: Record<string, string>;
}
export interface BackendUser { export interface BackendUser {
id: string; id: string;
account: string; account: string;
@ -17,6 +34,8 @@ export interface BackendUser {
roles: string[]; roles: string[];
/** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */ /** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */
permissions: Record<string, string[]>; permissions: Record<string, string[]>;
/** DB 메뉴 설정 SSOT */
menuConfig: MenuConfigItem[];
} }
export class LoginError extends Error { export class LoginError extends Error {

파일 보기

@ -81,11 +81,13 @@ export interface EnforcementPlan {
export async function getEnforcementRecords(params?: { export async function getEnforcementRecords(params?: {
violationType?: string; violationType?: string;
vesselMmsi?: string;
page?: number; page?: number;
size?: number; size?: number;
}): Promise<PageResponse<EnforcementRecord>> { }): Promise<PageResponse<EnforcementRecord>> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.violationType) query.set('violationType', params.violationType); if (params?.violationType) query.set('violationType', params.violationType);
if (params?.vesselMmsi) query.set('vesselMmsi', params.vesselMmsi);
query.set('page', String(params?.page ?? 0)); query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 20)); query.set('size', String(params?.size ?? 20));
const res = await fetch(`${API_BASE}/enforcement/records?${query}`, { const res = await fetch(`${API_BASE}/enforcement/records?${query}`, {

파일 보기

@ -30,6 +30,15 @@ export interface PredictionEvent {
resolvedAt: string | null; resolvedAt: string | null;
resolutionNote: string | null; resolutionNote: string | null;
createdAt: string; createdAt: string;
features?: {
dark_suspicion_score?: number;
dark_tier?: string;
dark_patterns?: string[];
dark_history_7d?: number;
transship_tier?: string;
transship_score?: number;
[key: string]: unknown;
} | null;
} }
export interface EventPageResponse { export interface EventPageResponse {

파일 보기

@ -29,6 +29,8 @@ interface EventStore {
loaded: boolean; loaded: boolean;
/** API 호출 */ /** API 호출 */
load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>; load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>;
/** 화면 깜박임 없는 백그라운드 갱신 (loading 상태 변경 없음) */
silentRefresh: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>;
loadStats: () => Promise<void>; loadStats: () => Promise<void>;
filterByLevel: (level: string | null) => LegacyEventRecord[]; filterByLevel: (level: string | null) => LegacyEventRecord[];
} }
@ -68,6 +70,23 @@ export const useEventStore = create<EventStore>((set, get) => ({
} }
}, },
silentRefresh: async (params) => {
try {
const res = await getEvents(params);
const legacy = res.content.map(toLegacyEvent);
set({
rawEvents: res.content,
events: legacy,
totalElements: res.totalElements,
totalPages: res.totalPages,
currentPage: res.number,
pageSize: res.size,
});
} catch {
// silent: 에러 무시 — 다음 갱신에서 재시도
}
},
loadStats: async () => { loadStats: async () => {
try { try {
const stats = await getEventStats(); const stats = await getEventStats();

파일 보기

@ -0,0 +1,66 @@
import { create } from 'zustand';
export interface MenuConfigItem {
menuCd: string;
parentMenuCd: string | null;
menuType: 'ITEM' | 'GROUP' | 'DIVIDER';
urlPath: string | null;
rsrcCd: string | null;
componentKey: string | null;
icon: string | null;
labelKey: string | null;
dividerLabel: string | null;
menuLevel: number;
sortOrd: number;
useYn: string;
labels: Record<string, string>;
}
/** DB labels에서 현재 언어의 라벨을 반환 */
export function getMenuLabel(item: MenuConfigItem, lang: string): string {
return item.labels?.[lang] || item.labels?.ko || item.menuCd;
}
interface MenuStore {
items: MenuConfigItem[];
loaded: boolean;
setMenuConfig: (items: MenuConfigItem[]) => void;
clear: () => void;
/** path → rsrcCd (longest-match, PATH_TO_RESOURCE 대체) */
getResourceForPath: (path: string) => string | undefined;
/** 최상위 항목 (menu_level=0, 사이드바 표시용) */
getTopLevelEntries: () => MenuConfigItem[];
/** 그룹 하위 항목 */
getChildren: (parentMenuCd: string) => MenuConfigItem[];
/** 라우팅 가능 항목 (ITEM + urlPath 보유) */
getRoutableItems: () => MenuConfigItem[];
}
export const useMenuStore = create<MenuStore>((set, get) => ({
items: [],
loaded: false,
setMenuConfig: (items) => set({ items, loaded: true }),
clear: () => set({ items: [], loaded: false }),
getResourceForPath: (path) => {
const { items } = get();
// longest-match: 가장 구체적인 경로 우선 (삽입 순서 의존 버그 해결)
const candidates = items.filter((i) => i.urlPath && i.rsrcCd);
candidates.sort((a, b) => b.urlPath!.length - a.urlPath!.length);
const match = candidates.find((i) => path.startsWith(i.urlPath!));
return match?.rsrcCd ?? undefined;
},
getTopLevelEntries: () =>
get().items.filter(
(i) => i.menuLevel === 0 && i.parentMenuCd === null && i.useYn !== 'H',
),
getChildren: (parentMenuCd) =>
get().items.filter((i) => i.parentMenuCd === parentMenuCd),
getRoutableItems: () =>
get().items.filter((i) => i.menuType === 'ITEM' && i.urlPath),
}));

파일 보기

@ -6,6 +6,7 @@
dedup: 동일 mmsi + category + 윈도우 중복 방지. dedup: 동일 mmsi + category + 윈도우 중복 방지.
""" """
import hashlib import hashlib
import json
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
@ -214,6 +215,10 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
event_uid = _make_event_uid(now, seq) event_uid = _make_event_uid(now, seq)
seq += 1 seq += 1
# features 추출: 이벤트에 연관된 핵심 특성만 저장
raw_features = result.get('features')
features_json = json.dumps(raw_features, ensure_ascii=False) if raw_features else None
events_to_insert.append(( events_to_insert.append((
event_uid, event_uid,
now, # occurred_at now, # occurred_at
@ -233,6 +238,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
result.get('confidence') or result.get('risk_score', 0) / 100.0, result.get('confidence') or result.get('risk_score', 0) / 100.0,
'NEW', # status 'NEW', # status
dedup_key, dedup_key,
features_json,
)) ))
generated += 1 generated += 1
# break 제거: 한 분석결과가 여러 룰에 매칭되면 모두 생성 # break 제거: 한 분석결과가 여러 룰에 매칭되면 모두 생성
@ -244,7 +250,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
f"""INSERT INTO {EVENTS_TABLE} f"""INSERT INTO {EVENTS_TABLE}
(event_uid, occurred_at, level, category, title, detail, (event_uid, occurred_at, level, category, title, detail,
vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn, vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn,
source_type, source_ref_id, ai_confidence, status, dedup_key) source_type, source_ref_id, ai_confidence, status, dedup_key, features)
VALUES %s VALUES %s
ON CONFLICT (event_uid) DO NOTHING""", ON CONFLICT (event_uid) DO NOTHING""",
events_to_insert, events_to_insert,