feat(phase4): SCAT~Rescue 6개 탭 Mock → API 전환 + 하드코딩 제거 #45

병합
htlee feature/scat-api-conversion 에서 develop 로 3 commits 를 머지했습니다 2026-03-01 01:44:02 +09:00
7개의 변경된 파일68개의 추가작업 그리고 59개의 파일을 삭제

파일 보기

@ -54,13 +54,15 @@ interface PredictionDetail {
insuranceData: unknown;
}>;
weather: Array<{
weatherDtm: string;
windSpd: number | null;
windDir: string | null;
waveHgt: number | null;
currentSpd: number | null;
currentDir: string | null;
temp: number | null;
obsDtm: string;
locNm: string;
temp: string;
weatherDc: string;
wind: string;
wave: string;
humid: string;
vis: string;
sst: string;
}>;
}
@ -244,16 +246,18 @@ export async function getAnalysisDetail(acdntSn: number): Promise<PredictionDeta
const weatherSql = `
SELECT
WEATHER_DTM,
WIND_SPD,
WIND_DIR,
WAVE_HGT,
CURRENT_SPD,
CURRENT_DIR,
TEMP
OBS_DTM,
LOC_NM,
TEMP,
WEATHER_DC,
WIND,
WAVE,
HUMID,
VIS,
SST
FROM ACDNT_WEATHER
WHERE ACDNT_SN = $1
ORDER BY WEATHER_DTM ASC
ORDER BY OBS_DTM ASC
`;
const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]);
@ -288,13 +292,15 @@ export async function getAnalysisDetail(acdntSn: number): Promise<PredictionDeta
}));
const weather = weatherRows.map((w: Record<string, unknown>) => ({
weatherDtm: String(w['weather_dtm'] ?? ''),
windSpd: w['wind_spd'] != null ? parseFloat(String(w['wind_spd'])) : null,
windDir: w['wind_dir'] != null ? String(w['wind_dir']) : null,
waveHgt: w['wave_hgt'] != null ? parseFloat(String(w['wave_hgt'])) : null,
currentSpd: w['current_spd'] != null ? parseFloat(String(w['current_spd'])) : null,
currentDir: w['current_dir'] != null ? String(w['current_dir']) : null,
temp: w['temp'] != null ? parseFloat(String(w['temp'])) : null,
obsDtm: w['obs_dtm'] ? String(w['obs_dtm']) : '',
locNm: String(w['loc_nm'] ?? ''),
temp: String(w['temp'] ?? ''),
weatherDc: String(w['weather_dc'] ?? ''),
wind: String(w['wind'] ?? ''),
wave: String(w['wave'] ?? ''),
humid: String(w['humid'] ?? ''),
vis: String(w['vis'] ?? ''),
sst: String(w['sst'] ?? ''),
}));
return {

파일 보기

@ -49,7 +49,13 @@ app.use(helmet({
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'", "http://localhost:*", "https://*.gc-si.dev", "https://*.data.go.kr", "https://*.khoa.go.kr"],
connectSrc: [
"'self'",
...(process.env.NODE_ENV !== 'production' ? ['http://localhost:*'] : []),
'https://*.gc-si.dev',
'https://*.data.go.kr',
'https://*.khoa.go.kr',
],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
@ -65,11 +71,12 @@ app.disable('x-powered-by')
// 3. CORS: 허용된 출처만 접근 가능
const allowedOrigins = [
'http://localhost:5173', // Vite dev server
'http://localhost:5174',
'http://localhost:3000',
'https://wing-demo.gc-si.dev',
process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL (추가 도메인)
process.env.FRONTEND_URL || 'https://wing-demo.gc-si.dev',
...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:3000',
] : []),
].filter(Boolean) as string[]
app.use(cors({

파일 보기

@ -6,6 +6,7 @@ import { LoginPage } from '@common/components/auth/LoginPage'
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
import { useAuthStore } from '@common/store/authStore'
import { useMenuStore } from '@common/store/menuStore'
import { API_BASE_URL } from '@common/services/api'
import { OilSpillView } from '@tabs/prediction'
import { ReportsView } from '@tabs/reports'
import { HNSView } from '@tabs/hns'
@ -46,8 +47,7 @@ function App() {
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
{ type: 'text/plain' }
)
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
navigator.sendBeacon(`${apiBase}/audit/log`, blob)
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
}, [activeMainTab, isAuthenticated])
// 세션 확인 중 스플래시

파일 보기

@ -9,6 +9,8 @@ import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { BacktrackReplayOverlay } from './BacktrackReplayOverlay'
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
// Fix Leaflet default icon issue
import icon from 'leaflet/dist/images/marker-icon.png'
import iconShadow from 'leaflet/dist/images/marker-shadow.png'
@ -190,7 +192,7 @@ export function MapView({
{wmsLayers.map(layer => (
<TileLayer
key={layer.id}
url={`http://localhost:8080/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true`}
url={`${GEOSERVER_URL}/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true`}
attribution='&copy; MPC GeoServer'
opacity={layerOpacity / 100}
/>

파일 보기

@ -1,6 +1,8 @@
import { useState } from 'react'
import type { InsuranceRow } from './assetTypes'
const DEFAULT_HAEWOON_API = import.meta.env.VITE_HAEWOON_API_URL || 'https://api.haewoon.or.kr/v1/insurance'
// 샘플 데이터 (외부 한국해운조합 API 연동 전 데모용)
const INSURANCE_DEMO_DATA: InsuranceRow[] = [
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
@ -13,7 +15,7 @@ const INSURANCE_DEMO_DATA: InsuranceRow[] = [
function ShipInsurance() {
const [apiConnected, setApiConnected] = useState(false)
const [showConfig, setShowConfig] = useState(false)
const [configEndpoint, setConfigEndpoint] = useState('https://api.haewoon.or.kr/v1/insurance')
const [configEndpoint, setConfigEndpoint] = useState(DEFAULT_HAEWOON_API)
const [configApiKey, setConfigApiKey] = useState('')
const [configKeyType, setConfigKeyType] = useState('mmsi')
const [configRespType, setConfigRespType] = useState('json')

파일 보기

@ -14,6 +14,7 @@ import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip,
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail } from '../services/predictionApi'
import type { PredictionDetail } from '../services/predictionApi'
import { api } from '@common/services/api'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
// eslint-disable-next-line react-refresh/only-export-components
@ -259,27 +260,16 @@ export function OilSpillView() {
const models = Array.from(selectedModels)
const results = await Promise.all(
models.map(async (model) => {
const response = await fetch('http://localhost:3001/api/simulation/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
lat: incidentCoord.lat,
lon: incidentCoord.lon,
duration_hours: predictionTime,
oil_type: oilType,
spill_amount: spillAmount,
spill_type: spillType
})
const { data } = await api.post<{ trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }> }>('/simulation/run', {
model,
lat: incidentCoord.lat,
lon: incidentCoord.lon,
duration_hours: predictionTime,
oil_type: oilType,
spill_amount: spillAmount,
spill_type: spillType,
})
if (!response.ok) {
throw new Error(`API 오류 (${model}): ${response.status}`)
}
const data = await response.json()
return (data.trajectory as Array<{ lat: number; lon: number; time: number; particle?: number }>)
.map(p => ({ ...p, model }))
return data.trajectory.map(p => ({ ...p, model }))
})
)

파일 보기

@ -54,13 +54,15 @@ export interface PredictionDetail {
insuranceData: unknown;
}>;
weather: Array<{
weatherDtm: string;
windSpd: number | null;
windDir: string | null;
waveHgt: number | null;
currentSpd: number | null;
currentDir: string | null;
temp: number | null;
obsDtm: string;
locNm: string;
temp: string;
weatherDc: string;
wind: string;
wave: string;
humid: string;
vis: string;
sst: string;
}>;
}