diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 93bb350..67be0a9 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -20,9 +20,10 @@ fi # Conventional Commits 정규식 # type(scope): subject # - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수) -# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택) -# - subject: 1~72자, 한/영 혼용 허용 (필수) -PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$' +# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택) +# - subject: 1자 이상 (길이는 바이트 기반 별도 검증) +PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$' +MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE") @@ -58,3 +59,13 @@ if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then echo "" exit 1 fi + +# 길이 검증 (바이트 기반 — UTF-8 한글 허용) +MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ') +if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then + echo "" + echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})" + echo " 현재 메시지: $FIRST_LINE" + echo "" + exit 1 +fi diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f141ab8..6b57c2e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -23,160 +23,6 @@ app.setErrorHandler((err, req, reply) => { app.get("/health", async () => ({ ok: true })); -const AIS_UPSTREAM_BASE = "http://211.208.115.83:8041"; -const AIS_UPSTREAM_PATH = "/snp-api/api/ais-target/search"; - -app.get<{ - Querystring: { - minutes?: string; - bbox?: string; - }; -}>("/api/ais-target/search", async (req, reply) => { - const minutesRaw = req.query.minutes ?? "60"; - const minutes = Number(minutesRaw); - if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 60 * 24) { - return reply.code(400).send({ success: false, message: "invalid minutes", data: [], errorCode: "BAD_REQUEST" }); - } - - const bboxRaw = req.query.bbox; - const bbox = parseBbox(bboxRaw); - if (bboxRaw && !bbox) { - return reply.code(400).send({ success: false, message: "invalid bbox", data: [], errorCode: "BAD_REQUEST" }); - } - - const u = new URL(AIS_UPSTREAM_PATH, AIS_UPSTREAM_BASE); - u.searchParams.set("minutes", String(minutes)); - - const controller = new AbortController(); - const timeoutMs = 20_000; - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } }); - const txt = await res.text(); - if (!res.ok) { - req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "AIS upstream error"); - return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" }); - } - - // Apply optional bbox filtering server-side to reduce payload to the browser. - let json: { data?: unknown; message?: string }; - try { - json = JSON.parse(txt) as { data?: unknown; message?: string }; - } catch (e) { - req.log.warn({ err: e, body: txt.slice(0, 2000) }, "AIS upstream returned invalid JSON"); - return reply - .code(502) - .send({ success: false, message: "upstream invalid json", data: [], errorCode: "UPSTREAM_INVALID_JSON" }); - } - if (!json || typeof json !== "object") { - req.log.warn({ body: txt.slice(0, 2000) }, "AIS upstream returned non-object JSON"); - return reply - .code(502) - .send({ success: false, message: "upstream invalid payload", data: [], errorCode: "UPSTREAM_INVALID_PAYLOAD" }); - } - const rows = Array.isArray(json.data) ? (json.data as unknown[]) : []; - const filtered = bbox - ? rows.filter((r) => { - if (!r || typeof r !== "object") return false; - const lat = (r as { lat?: unknown }).lat; - const lon = (r as { lon?: unknown }).lon; - if (typeof lat !== "number" || typeof lon !== "number") return false; - return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax; - }) - : rows; - - if (bbox) { - json.message = `${json.message ?? ""} (bbox: ${filtered.length}/${rows.length})`.trim(); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (json as any).data = filtered; - - reply.type("application/json").send(json); - } catch (e) { - const name = e instanceof Error ? e.name : ""; - const isTimeout = name === "AbortError"; - req.log.warn({ err: e, url: u.toString() }, "AIS proxy request failed"); - return reply.code(isTimeout ? 504 : 502).send({ - success: false, - message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed", - data: [], - errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED", - }); - } finally { - clearTimeout(timeout); - } -}); - -function parseBbox(raw: string | undefined) { - if (!raw) return null; - const parts = raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - if (parts.length !== 4) return null; - - const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p)); - const ok = - Number.isFinite(lonMin) && - Number.isFinite(latMin) && - Number.isFinite(lonMax) && - Number.isFinite(latMax) && - lonMin >= -180 && - lonMax <= 180 && - latMin >= -90 && - latMax <= 90 && - lonMin < lonMax && - latMin < latMax; - if (!ok) return null; - return { lonMin, latMin, lonMax, latMax }; -} - -app.get<{ - Params: { mmsi: string }; - Querystring: { minutes?: string }; -}>("/api/ais-target/:mmsi/track", async (req, reply) => { - const mmsiRaw = req.params.mmsi; - const mmsi = Number(mmsiRaw); - if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) { - return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" }); - } - - const minutesRaw = req.query.minutes ?? "360"; - const minutes = Number(minutesRaw); - if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) { - return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" }); - } - - const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE); - u.searchParams.set("minutes", String(minutes)); - - const controller = new AbortController(); - const timeoutMs = 20_000; - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } }); - const txt = await res.text(); - if (!res.ok) { - req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error"); - return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" }); - } - - reply.type("application/json").send(txt); - } catch (e) { - const name = e instanceof Error ? e.name : ""; - const isTimeout = name === "AbortError"; - req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed"); - return reply.code(isTimeout ? 504 : 502).send({ - success: false, - message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed", - data: [], - errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED", - }); - } finally { - clearTimeout(timeout); - } -}); - app.get("/zones", async (_req, reply) => { const zonesPath = path.resolve( process.cwd(), diff --git a/apps/web/src/entities/aisTarget/api/dto.ts b/apps/web/src/entities/aisTarget/api/dto.ts new file mode 100644 index 0000000..0cdc986 --- /dev/null +++ b/apps/web/src/entities/aisTarget/api/dto.ts @@ -0,0 +1,34 @@ +/** GET /api/v2/vessels/chnprmship/recent-positions 응답 항목 */ +export interface ChnPrmShipPositionDto { + mmsi: string; + imo: number; + name: string; + callsign: string; + vesselType: string; + lat: number; + lon: number; + sog: number; + cog: number; + heading: number; + length: number; + width: number; + draught: number; + destination: string; + status: string; + signalKindCode: string; + messageTimestamp: string; +} + +/** GET /api/v2/vessels/recent-positions 응답 항목 */ +export interface RecentVesselPositionDto { + mmsi: string; + lon: number; + lat: number; + sog: number; + cog: number; + shipNm: string; + shipTy: string; + shipKindCode: string; + nationalCode: string; + lastUpdate: string; +} diff --git a/apps/web/src/entities/aisTarget/api/fetchPositions.ts b/apps/web/src/entities/aisTarget/api/fetchPositions.ts new file mode 100644 index 0000000..9df5c75 --- /dev/null +++ b/apps/web/src/entities/aisTarget/api/fetchPositions.ts @@ -0,0 +1,105 @@ +import type { AisTarget } from '../model/types'; +import type { ChnPrmShipPositionDto, RecentVesselPositionDto } from './dto'; + +const SIGNAL_BATCH_BASE = '/signal-batch'; + +/* ── 내부 어댑터 ── */ + +function adaptChnPrmShip(dto: ChnPrmShipPositionDto): AisTarget { + return { + mmsi: Number(dto.mmsi), + imo: dto.imo ?? 0, + name: dto.name ?? '', + callsign: dto.callsign ?? '', + vesselType: dto.vesselType ?? '', + lat: dto.lat, + lon: dto.lon, + heading: dto.heading ?? 0, + sog: dto.sog ?? 0, + cog: dto.cog ?? 0, + rot: 0, + length: dto.length ?? 0, + width: dto.width ?? 0, + draught: dto.draught ?? 0, + destination: dto.destination ?? '', + eta: '', + status: dto.status ?? '', + messageTimestamp: dto.messageTimestamp ?? '', + receivedDate: '', + source: 'chnprmship', + classType: '', + signalKindCode: dto.signalKindCode, + }; +} + +function adaptRecentVessel(dto: RecentVesselPositionDto): AisTarget { + return { + mmsi: Number(dto.mmsi), + imo: 0, + name: dto.shipNm ?? '', + callsign: '', + vesselType: dto.shipTy ?? '', + lat: dto.lat, + lon: dto.lon, + heading: 0, + sog: dto.sog ?? 0, + cog: dto.cog ?? 0, + rot: 0, + length: 0, + width: 0, + draught: 0, + destination: '', + eta: '', + status: '', + messageTimestamp: dto.lastUpdate ?? '', + receivedDate: '', + source: 'recent', + classType: '', + shipKindCode: dto.shipKindCode, + nationalCode: dto.nationalCode, + }; +} + +/* ── 공개 API ── */ + +/** 허가선박 최근 위치 → AisTarget[] */ +export async function fetchChnPrmShipPositions( + params: { minutes: number }, + signal?: AbortSignal, +): Promise { + const u = new URL( + `${SIGNAL_BATCH_BASE}/api/v2/vessels/chnprmship/recent-positions`, + window.location.origin, + ); + u.searchParams.set('minutes', String(params.minutes)); + + const res = await fetch(u, { signal, headers: { accept: 'application/json' } }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`ChnPrmShip API ${res.status}: ${txt.slice(0, 200)}`); + } + const json: unknown = await res.json(); + const dtos: ChnPrmShipPositionDto[] = Array.isArray(json) ? json : []; + return dtos.map(adaptChnPrmShip); +} + +/** 전체 선박 최근 위치 → AisTarget[] */ +export async function fetchRecentPositions( + params: { minutes: number }, + signal?: AbortSignal, +): Promise { + const u = new URL( + `${SIGNAL_BATCH_BASE}/api/v2/vessels/recent-positions`, + window.location.origin, + ); + u.searchParams.set('minutes', String(params.minutes)); + + const res = await fetch(u, { signal, headers: { accept: 'application/json' } }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`RecentPositions API ${res.status}: ${txt.slice(0, 200)}`); + } + const json: unknown = await res.json(); + const dtos: RecentVesselPositionDto[] = Array.isArray(json) ? json : []; + return dtos.map(adaptRecentVessel); +} diff --git a/apps/web/src/entities/aisTarget/api/searchAisTargets.ts b/apps/web/src/entities/aisTarget/api/searchAisTargets.ts deleted file mode 100644 index b6bcdb3..0000000 --- a/apps/web/src/entities/aisTarget/api/searchAisTargets.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AisTargetSearchResponse } from "../model/types"; - -export type SearchAisTargetsParams = { - minutes: number; - bbox?: string; - centerLon?: number; - centerLat?: number; - radiusMeters?: number; -}; - -export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) { - // Same convention as the "dark" project: - // - dev: default to Vite proxy base `/snp-api` - // - prod/other: can be overridden via `VITE_API_URL` (e.g. `http://host:8041/snp-api`) - const base = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, ""); - const u = new URL(`${base}/api/ais-target/search`, window.location.origin); - u.searchParams.set("minutes", String(params.minutes)); - if (params.bbox) u.searchParams.set("bbox", params.bbox); - if (typeof params.centerLon === "number" && Number.isFinite(params.centerLon)) { - u.searchParams.set("centerLon", String(params.centerLon)); - } - if (typeof params.centerLat === "number" && Number.isFinite(params.centerLat)) { - u.searchParams.set("centerLat", String(params.centerLat)); - } - if (typeof params.radiusMeters === "number" && Number.isFinite(params.radiusMeters)) { - u.searchParams.set("radiusMeters", String(params.radiusMeters)); - } - - const res = await fetch(u, { signal, headers: { accept: "application/json" } }); - const txt = await res.text(); - let json: unknown = null; - try { - json = JSON.parse(txt); - } catch { - // ignore - } - if (!res.ok) { - const msg = - json && typeof json === "object" && typeof (json as { message?: unknown }).message === "string" - ? (json as { message: string }).message - : txt.slice(0, 200) || res.statusText; - throw new Error(`AIS target API failed: ${res.status} ${msg}`); - } - - if (!json || typeof json !== "object") throw new Error("AIS target API returned invalid payload"); - const parsed = json as AisTargetSearchResponse; - if (!parsed.success) throw new Error(parsed.message || "AIS target API returned success=false"); - - return parsed; -} diff --git a/apps/web/src/entities/aisTarget/api/searchChnprmship.ts b/apps/web/src/entities/aisTarget/api/searchChnprmship.ts deleted file mode 100644 index 272e3c7..0000000 --- a/apps/web/src/entities/aisTarget/api/searchChnprmship.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AisTargetSearchResponse } from '../model/types'; - -export async function searchChnprmship( - params: { minutes: number }, - signal?: AbortSignal, -): Promise { - const base = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); - const u = new URL(`${base}/api/ais-target/chnprmship`, window.location.origin); - u.searchParams.set('minutes', String(params.minutes)); - - const res = await fetch(u, { signal, headers: { accept: 'application/json' } }); - const txt = await res.text(); - let json: unknown = null; - try { - json = JSON.parse(txt); - } catch { - // ignore - } - if (!res.ok) { - const msg = - json && typeof json === 'object' && typeof (json as { message?: unknown }).message === 'string' - ? (json as { message: string }).message - : txt.slice(0, 200) || res.statusText; - throw new Error(`chnprmship API failed: ${res.status} ${msg}`); - } - - if (!json || typeof json !== 'object') throw new Error('chnprmship API returned invalid payload'); - const parsed = json as AisTargetSearchResponse; - if (!parsed.success) throw new Error(parsed.message || 'chnprmship API returned success=false'); - - return parsed; -} diff --git a/apps/web/src/entities/aisTarget/model/types.ts b/apps/web/src/entities/aisTarget/model/types.ts index b89b089..62c2337 100644 --- a/apps/web/src/entities/aisTarget/model/types.ts +++ b/apps/web/src/entities/aisTarget/model/types.ts @@ -20,12 +20,8 @@ export type AisTarget = { receivedDate: string; source: string; classType: string; -}; - -export type AisTargetSearchResponse = { - success: boolean; - message: string; - data: AisTarget[]; - errorCode: string | null; + signalKindCode?: string; + shipKindCode?: string; + nationalCode?: string; }; diff --git a/apps/web/src/entities/vesselTrack/api/fetchTrack.ts b/apps/web/src/entities/vesselTrack/api/fetchTrack.ts deleted file mode 100644 index 910b203..0000000 --- a/apps/web/src/entities/vesselTrack/api/fetchTrack.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { TrackResponse } from '../model/types'; - -const API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); - -export async function fetchVesselTrack( - mmsi: number, - minutes: number, - signal?: AbortSignal, -): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - - const combinedSignal = signal ?? controller.signal; - - try { - const url = `${API_BASE}/api/ais-target/${mmsi}/track?minutes=${minutes}`; - const res = await fetch(url, { - signal: combinedSignal, - headers: { accept: 'application/json' }, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Track API error ${res.status}: ${text.slice(0, 200)}`); - } - - const json = (await res.json()) as TrackResponse; - return json; - } finally { - clearTimeout(timeout); - } -} diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index 59b1aff..c4a77d9 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -1,9 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets"; -import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship"; -import type { AisTarget } from "../../entities/aisTarget/model/types"; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { fetchChnPrmShipPositions, fetchRecentPositions } from '../../entities/aisTarget/api/fetchPositions'; +import type { AisTarget } from '../../entities/aisTarget/model/types'; -export type AisPollingStatus = "idle" | "loading" | "ready" | "error"; +export type AisPollingStatus = 'idle' | 'loading' | 'ready' | 'error'; export type AisPollingSnapshot = { status: AisPollingStatus; @@ -17,32 +16,31 @@ export type AisPollingSnapshot = { lastDeleted: number; }; -export type AisPollingOptions = { - /** 초기 chnprmship API 호출 시 minutes (기본 120) */ - chnprmshipMinutes?: number; - /** 주기적 폴링 시 search API minutes (기본 2) */ - incrementalMinutes?: number; - /** 폴링 주기 ms (기본 60_000) */ - intervalMs?: number; - /** 보존 기간 (기본 chnprmshipMinutes) */ - retentionMinutes?: number; - /** incremental 폴링 시 bbox 필터 */ +export interface AisPollingOptions { + chnprmship?: { + initialMinutes?: number; + pollMinutes?: number; + intervalMs?: number; + retentionMinutes?: number; + }; + recent?: { + initialMinutes?: number; + pollMinutes?: number; + intervalMs?: number; + retentionMinutes?: number; + }; bbox?: string; - /** incremental 폴링 시 중심 경도 */ - centerLon?: number; - /** incremental 폴링 시 중심 위도 */ - centerLat?: number; - /** incremental 폴링 시 반경(m) */ - radiusMeters?: number; enabled?: boolean; -}; +} + +/* ── Store helpers ── */ function upsertByMmsi(store: Map, rows: AisTarget[]) { let inserted = 0; let upserted = 0; for (const r of rows) { - if (!r || typeof r.mmsi !== "number") continue; + if (!r || typeof r.mmsi !== 'number') continue; const prev = store.get(r.mmsi); if (!prev) { @@ -52,10 +50,17 @@ function upsertByMmsi(store: Map, rows: AisTarget[]) { continue; } - // Keep newer rows only. If backend returns same/older timestamp, skip. - const prevTs = Date.parse(prev.messageTimestamp || ""); - const nextTs = Date.parse(r.messageTimestamp || ""); - if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue; + // 소스 우선순위: chnprmship > recent + // recent 데이터는 기존 chnprmship 데이터를 절대 덮어쓰지 않음 + if (prev.source === 'chnprmship' && r.source === 'recent') continue; + + // 동일 소스: 더 최신 timestamp만 업데이트 + const prevTs = Date.parse(prev.messageTimestamp || ''); + const nextTs = Date.parse(r.messageTimestamp || ''); + if (prev.source === r.source) { + if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue; + } + // 다른 소스 (recent→chnprmship): chnprmship이 항상 승리 store.set(r.mmsi, r); upserted += 1; @@ -67,7 +72,7 @@ function upsertByMmsi(store: Map, rows: AisTarget[]) { function parseBbox(raw: string | undefined) { if (!raw) return null; const parts = raw - .split(",") + .split(',') .map((s) => s.trim()) .filter(Boolean); if (parts.length !== 4) return null; @@ -87,13 +92,21 @@ function parseBbox(raw: string | undefined) { return { lonMin, latMin, lonMax, latMax }; } -function pruneStore(store: Map, retentionMinutes: number, bboxRaw: string | undefined) { - const cutoffMs = Date.now() - retentionMinutes * 60_000; +function pruneStore( + store: Map, + chnprmRetention: number, + recentRetention: number, + bboxRaw: string | undefined, +) { + const now = Date.now(); const bbox = parseBbox(bboxRaw); let deleted = 0; for (const [mmsi, t] of store.entries()) { - const ts = Date.parse(t.messageTimestamp || ""); + const ts = Date.parse(t.messageTimestamp || ''); + const retention = t.source === 'chnprmship' ? chnprmRetention : recentRetention; + const cutoffMs = now - retention * 60_000; + if (Number.isFinite(ts) && ts < cutoffMs) { store.delete(mmsi); deleted += 1; @@ -103,7 +116,7 @@ function pruneStore(store: Map, retentionMinutes: number, bbo if (bbox) { const lat = t.lat; const lon = t.lon; - if (typeof lat !== "number" || typeof lon !== "number") { + if (typeof lat !== 'number' || typeof lon !== 'number') { store.delete(mmsi); deleted += 1; continue; @@ -119,23 +132,28 @@ function pruneStore(store: Map, retentionMinutes: number, bbo return deleted; } +/* ── Hook ── */ + export function useAisTargetPolling(opts: AisPollingOptions = {}) { - const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120; - const incrementalMinutes = opts.incrementalMinutes ?? 2; - const intervalMs = opts.intervalMs ?? 60_000; - const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes; - const enabled = opts.enabled ?? true; + const cp = opts.chnprmship ?? {}; + const rc = opts.recent ?? {}; + const cpInit = cp.initialMinutes ?? 120; + const cpPoll = cp.pollMinutes ?? 2; + const cpInterval = cp.intervalMs ?? 60_000; + const cpRetention = cp.retentionMinutes ?? 120; + const rcInit = rc.initialMinutes ?? 15; + const rcPoll = rc.pollMinutes ?? 12; + const rcInterval = rc.intervalMs ?? 600_000; + const rcRetention = rc.retentionMinutes ?? 72; const bbox = opts.bbox; - const centerLon = opts.centerLon; - const centerLat = opts.centerLat; - const radiusMeters = opts.radiusMeters; + const enabled = opts.enabled ?? true; const storeRef = useRef>(new Map()); const generationRef = useRef(0); const [rev, setRev] = useState(0); const [snapshot, setSnapshot] = useState({ - status: "idle", + status: 'idle', error: null, lastFetchAt: null, lastFetchMinutes: null, @@ -153,19 +171,19 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { const controller = new AbortController(); const generation = ++generationRef.current; - function applyResult(res: { data: AisTarget[]; message: string }, minutes: number) { + function applyData(data: AisTarget[], minutes: number) { if (cancelled || generation !== generationRef.current) return; - const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); - const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); + const { inserted, upserted } = upsertByMmsi(storeRef.current, data); + const deleted = pruneStore(storeRef.current, cpRetention, rcRetention, bbox); const total = storeRef.current.size; setSnapshot({ - status: "ready", + status: 'ready', error: null, lastFetchAt: new Date().toISOString(), lastFetchMinutes: minutes, - lastMessage: res.message, + lastMessage: null, total, lastUpserted: upserted, lastInserted: inserted, @@ -174,29 +192,10 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { setRev((r) => r + 1); } - async function runInitial(minutes: number) { + async function pollChnprm(minutes: number) { try { - setSnapshot((s) => ({ ...s, status: "loading", error: null })); - const res = await searchChnprmship({ minutes }, controller.signal); - applyResult(res, minutes); - } catch (e) { - if (cancelled || generation !== generationRef.current) return; - setSnapshot((s) => ({ - ...s, - status: "error", - error: e instanceof Error ? e.message : String(e), - })); - } - } - - async function runIncremental(minutes: number) { - try { - setSnapshot((s) => ({ ...s, error: null })); - const res = await searchAisTargets( - { minutes, bbox, centerLon, centerLat, radiusMeters }, - controller.signal, - ); - applyResult(res, minutes); + const data = await fetchChnPrmShipPositions({ minutes }, controller.signal); + applyData(data, minutes); } catch (e) { if (cancelled || generation !== generationRef.current) return; setSnapshot((s) => ({ @@ -206,10 +205,23 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { } } - // Reset store when polling config changes. + async function pollRecent(minutes: number) { + try { + const data = await fetchRecentPositions({ minutes }, controller.signal); + applyData(data, minutes); + } catch (e) { + if (cancelled || generation !== generationRef.current) return; + setSnapshot((s) => ({ + ...s, + error: e instanceof Error ? e.message : String(e), + })); + } + } + + // 초기화: 스토어 리셋 + 두 API 병렬 호출 storeRef.current = new Map(); setSnapshot({ - status: "loading", + status: 'loading', error: null, lastFetchAt: null, lastFetchMinutes: null, @@ -221,31 +233,21 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }); setRev((r) => r + 1); - // 초기 로드: chnprmship API 1회 호출 - void runInitial(chnprmshipMinutes); + void Promise.all([pollChnprm(cpInit), pollRecent(rcInit)]); - // 주기적 폴링: search API로 incremental 업데이트 - const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs); + // 이중 타이머: ChnPrmShip 1분, 전체 선박 10분 + const cpTimer = window.setInterval(() => void pollChnprm(cpPoll), cpInterval); + const rcTimer = window.setInterval(() => void pollRecent(rcPoll), rcInterval); return () => { cancelled = true; controller.abort(); - window.clearInterval(id); + window.clearInterval(cpTimer); + window.clearInterval(rcTimer); }; - }, [ - chnprmshipMinutes, - incrementalMinutes, - intervalMs, - retentionMinutes, - bbox, - centerLon, - centerLat, - radiusMeters, - enabled, - ]); + }, [cpInit, cpPoll, cpInterval, cpRetention, rcInit, rcPoll, rcInterval, rcRetention, bbox, enabled]); const targets = useMemo(() => { - // `rev` is a version counter so we recompute the array snapshot when the store changes. void rev; return Array.from(storeRef.current.values()); }, [rev]); diff --git a/apps/web/src/features/trackReplay/services/trackQueryService.ts b/apps/web/src/features/trackReplay/services/trackQueryService.ts index 8435151..d1e93eb 100644 --- a/apps/web/src/features/trackReplay/services/trackQueryService.ts +++ b/apps/web/src/features/trackReplay/services/trackQueryService.ts @@ -1,5 +1,3 @@ -import { fetchVesselTrack } from '../../../entities/vesselTrack/api/fetchTrack'; -import { convertLegacyTrackPointsToProcessedTrack } from '../lib/adapters'; import type { ProcessedTrack } from '../model/track.types'; type QueryTrackByMmsiParams = { @@ -8,6 +6,7 @@ type QueryTrackByMmsiParams = { shipNameHint?: string; shipKindCodeHint?: string; nationalCodeHint?: string; + isPermitted?: boolean; }; type V2TrackResponse = { @@ -24,6 +23,12 @@ type V2TrackResponse = { avgSpeed?: number; maxSpeed?: number; pointCount?: number; + chnPrmShipInfo?: { + name?: string; + vesselType?: string; + callsign?: string; + imo?: number; + }; }; function normalizeTimestampMs(value: string | number): number { @@ -77,11 +82,13 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] { const targetId = row.targetId || row.vesselId || ''; const sigSrcCd = row.sigSrcCd || '000001'; + const chnName = row.chnPrmShipInfo?.name?.trim(); + out.push({ vesselId: row.vesselId || `${sigSrcCd}_${targetId}`, targetId, sigSrcCd, - shipName: (row.shipName || '').trim() || targetId, + shipName: chnName || (row.shipName || '').trim() || targetId, shipKindCode: row.shipKindCode || '000027', nationalCode: row.nationalCode || '', geometry, @@ -99,33 +106,17 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] { return out; } -async function queryLegacyTrack(params: QueryTrackByMmsiParams): Promise { - const response = await fetchVesselTrack(params.mmsi, params.minutes); - if (!response.success || response.data.length === 0) return []; - - const converted = convertLegacyTrackPointsToProcessedTrack(params.mmsi, response.data, { - shipName: params.shipNameHint, - shipKindCode: params.shipKindCodeHint, - nationalCode: params.nationalCodeHint, - }); - - return converted ? [converted] : []; -} - async function queryV2Track(params: QueryTrackByMmsiParams): Promise { - const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '').trim(); - if (!base) { - return queryLegacyTrack(params); - } + const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim(); const end = new Date(); const start = new Date(end.getTime() - params.minutes * 60_000); const requestBody = { - startTime: start.toISOString().slice(0, 19), - endTime: end.toISOString().slice(0, 19), - vessels: [{ sigSrcCd: '000001', targetId: String(params.mmsi) }], - isIntegration: '0', + startTime: start.toISOString(), + endTime: end.toISOString(), + vessels: [String(params.mmsi)], + includeChnPrmShip: params.isPermitted ?? false, }; const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`; @@ -136,7 +127,7 @@ async function queryV2Track(params: QueryTrackByMmsiParams): Promise 0) return converted; - return queryLegacyTrack(params); + return convertV2Tracks(rows); } export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise { - const mode = String(import.meta.env.VITE_TRACK_SOURCE_MODE || 'legacy').toLowerCase(); - - if (mode === 'v2') { - return queryV2Track(params); - } - - return queryLegacyTrack(params); + return queryV2Track(params); } diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 1e69fdf..860b7b5 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -41,12 +41,6 @@ import { useDashboardState } from "./useDashboardState"; import type { Bbox } from "./useDashboardState"; import { DashboardSidebar } from "./DashboardSidebar"; -const AIS_CENTER = { - lon: 126.95, - lat: 35.95, - radiusMeters: 2_000_000, -}; - function inBbox(lon: number, lat: number, bbox: Bbox) { const [lonMin, latMin, lonMax, latMax] = bbox; if (lat < latMin || lat > latMax) return false; @@ -108,40 +102,11 @@ export function DashboardPage() { // ── AIS polling ── const { targets, snapshot } = useAisTargetPolling({ - chnprmshipMinutes: 120, - incrementalMinutes: 2, - intervalMs: 60_000, - retentionMinutes: 120, + chnprmship: { initialMinutes: 120, pollMinutes: 2, intervalMs: 60_000, retentionMinutes: 120 }, + recent: { initialMinutes: 15, pollMinutes: 12, intervalMs: 600_000, retentionMinutes: 72 }, bbox: useApiBbox ? apiBbox : undefined, - centerLon: useApiBbox ? undefined : AIS_CENTER.lon, - centerLat: useApiBbox ? undefined : AIS_CENTER.lat, - radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters, }); - // ── Track request ── - const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { - const trackStore = useTrackQueryStore.getState(); - const queryKey = `${mmsi}:${minutes}:${Date.now()}`; - trackStore.beginQuery(queryKey); - - try { - const target = targets.find((item) => item.mmsi === mmsi); - const tracks = await queryTrackByMmsi({ - mmsi, - minutes, - shipNameHint: target?.name, - }); - - if (tracks.length > 0) { - trackStore.applyTracksSuccess(tracks, queryKey); - } else { - trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey); - } - } catch (e) { - trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); - } - }, [targets]); - // ── Derived data ── const targetsInScope = useMemo(() => { const base = (!useViewportFilter || !viewBbox) @@ -157,6 +122,35 @@ export function DashboardPage() { } return hits; }, [targetsInScope, legacyIndex, isDevMode]); + + // ── Track request ── + const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { + const trackStore = useTrackQueryStore.getState(); + const queryKey = `${mmsi}:${minutes}:${Date.now()}`; + trackStore.beginQuery(queryKey); + + try { + const target = targets.find((item) => item.mmsi === mmsi); + const isPermitted = legacyHits.has(mmsi); + const tracks = await queryTrackByMmsi({ + mmsi, + minutes, + shipNameHint: target?.name, + shipKindCodeHint: target?.shipKindCode, + nationalCodeHint: target?.nationalCode, + isPermitted, + }); + + if (tracks.length > 0) { + trackStore.applyTracksSuccess(tracks, queryKey); + } else { + trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey); + } + } catch (e) { + trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); + } + }, [targets, legacyHits]); + const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]); const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a609140..b885814 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,10 +10,9 @@ export default defineConfig(({ mode }) => { const webPort = Number(env.WEB_PORT || process.env.WEB_PORT || 5175); const apiPort = Number(env.API_PORT || process.env.API_PORT || 5174); - // Same proxy pattern as the "dark" project: - // - dev: use Vite proxy (/snp-api -> upstream host) - // - prod: set VITE_API_URL to absolute base if needed - const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041"; + // dev: use Vite proxy → upstream + // prod: set VITE_API_URL to absolute base if needed + const signalBatchTarget = env.VITE_SIGNAL_BATCH_TARGET || process.env.VITE_SIGNAL_BATCH_TARGET || "https://wing.gc-si.dev"; return { plugins: [tailwindcss(), react()], @@ -35,9 +34,9 @@ export default defineConfig(({ mode }) => { target: `http://127.0.0.1:${apiPort}`, changeOrigin: true, }, - // SNP-Batch AIS upstream (ship positions) - "/snp-api": { - target: snpApiTarget, + // signal-batch API (선박 위치/항적) + "/signal-batch": { + target: signalBatchTarget, changeOrigin: true, secure: false, },