feat(api): signal-batch v2 API 마이그레이션 (#32)
This commit is contained in:
부모
1e742e5a3d
커밋
71f235097c
@ -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(),
|
||||
|
||||
34
apps/web/src/entities/aisTarget/api/dto.ts
Normal file
34
apps/web/src/entities/aisTarget/api/dto.ts
Normal file
@ -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;
|
||||
}
|
||||
105
apps/web/src/entities/aisTarget/api/fetchPositions.ts
Normal file
105
apps/web/src/entities/aisTarget/api/fetchPositions.ts
Normal file
@ -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<AisTarget[]> {
|
||||
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<AisTarget[]> {
|
||||
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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import type { AisTargetSearchResponse } from '../model/types';
|
||||
|
||||
export async function searchChnprmship(
|
||||
params: { minutes: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<AisTargetSearchResponse> {
|
||||
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;
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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<TrackResponse> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<number, AisTarget>, 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<number, AisTarget>, 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<number, AisTarget>, 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<number, AisTarget>, retentionMinutes: number, bboxRaw: string | undefined) {
|
||||
const cutoffMs = Date.now() - retentionMinutes * 60_000;
|
||||
function pruneStore(
|
||||
store: Map<number, AisTarget>,
|
||||
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<number, AisTarget>, 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<number, AisTarget>, 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<Map<number, AisTarget>>(new Map());
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const [rev, setRev] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
|
||||
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]);
|
||||
|
||||
@ -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<ProcessedTrack[]> {
|
||||
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<ProcessedTrack[]> {
|
||||
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<ProcessedTr
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return queryLegacyTrack(params);
|
||||
throw new Error(`Track API ${res.status}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as unknown;
|
||||
@ -146,17 +137,9 @@ async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTr
|
||||
? ((json as { data: V2TrackResponse[] }).data)
|
||||
: [];
|
||||
|
||||
const converted = convertV2Tracks(rows);
|
||||
if (converted.length > 0) return converted;
|
||||
return queryLegacyTrack(params);
|
||||
return convertV2Tracks(rows);
|
||||
}
|
||||
|
||||
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
|
||||
const mode = String(import.meta.env.VITE_TRACK_SOURCE_MODE || 'legacy').toLowerCase();
|
||||
|
||||
if (mode === 'v2') {
|
||||
return queryV2Track(params);
|
||||
}
|
||||
|
||||
return queryLegacyTrack(params);
|
||||
return queryV2Track(params);
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user