import { Router, Request, Response } from 'express' import { isValidLatitude, isValidLongitude, isValidNumber, isAllowedValue, isValidStringLength, escapeHtml, } from '../middleware/security.js' const router = Router() // 허용된 모델 목록 (화이트리스트) const ALLOWED_MODELS = ['KOSPS', 'POSEIDON', 'OpenDrift', '앙상블'] as const type AllowedModel = typeof ALLOWED_MODELS[number] // 허용된 유종 목록 const ALLOWED_OIL_TYPES = ['원유', '벙커C유', '경유', '휘발유', '등유', '윤활유', '기타'] as const // 허용된 유출 유형 목록 const ALLOWED_SPILL_TYPES = ['연속유출', '순간유출'] as const interface ParticlePoint { lat: number lon: number time: number particle: number } /** * POST /api/simulation/run * 오일 확산 시뮬레이션 실행 * * 보안 조치: * - 화이트리스트 기반 모델명 검증 * - 좌표 범위 검증 (위도 -90~90, 경도 -180~180) * - 숫자 범위 검증 (duration, spill_amount) * - 문자열 길이 제한 */ router.post('/run', async (req: Request, res: Response) => { try { const { model, lat, lon, duration_hours, oil_type, spill_amount, spill_type } = req.body // 1. 필수 파라미터 존재 검증 if (model === undefined || lat === undefined || lon === undefined || duration_hours === undefined) { return res.status(400).json({ error: '필수 파라미터 누락', required: ['model', 'lat', 'lon', 'duration_hours'] }) } // 2. 모델명 화이트리스트 검증 if (!isAllowedValue(model, [...ALLOWED_MODELS])) { return res.status(400).json({ error: '유효하지 않은 모델', message: `허용된 모델: ${ALLOWED_MODELS.join(', ')}`, }) } // 3. 위도/경도 범위 검증 if (!isValidLatitude(lat)) { return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90 ~ 90 범위의 숫자여야 합니다.' }) } if (!isValidLongitude(lon)) { return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180 ~ 180 범위의 숫자여야 합니다.' }) } // 4. 예측 시간 범위 검증 (1~720시간 = 최대 30일) if (!isValidNumber(duration_hours, 1, 720)) { return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위의 숫자여야 합니다.' }) } // 5. 선택적 파라미터 검증 if (oil_type !== undefined) { if (typeof oil_type !== 'string' || !isValidStringLength(oil_type, 50)) { return res.status(400).json({ error: '유효하지 않은 유종' }) } } if (spill_amount !== undefined) { if (!isValidNumber(spill_amount, 0, 1000000)) { return res.status(400).json({ error: '유효하지 않은 유출량', message: '유출량은 0~1,000,000 범위의 숫자여야 합니다.' }) } } if (spill_type !== undefined) { if (typeof spill_type !== 'string' || !isValidStringLength(spill_type, 50)) { return res.status(400).json({ error: '유효하지 않은 유출 유형' }) } } // 검증 완료 - 시뮬레이션 실행 const trajectory = generateDemoTrajectory( lat, lon, duration_hours, model, 20 ) res.json({ success: true, model: escapeHtml(String(model)), parameters: { lat, lon, duration_hours, oil_type: oil_type ? escapeHtml(String(oil_type)) : undefined, spill_amount, spill_type: spill_type ? escapeHtml(String(spill_type)) : undefined, }, trajectory, metadata: { particle_count: 20, time_steps: duration_hours + 1, generated_at: new Date().toISOString() } }) } catch { // 내부 오류 메시지 노출 방지 res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' }) } }) /** * 데모 궤적 데이터 생성 */ function generateDemoTrajectory( startLat: number, startLon: number, hours: number, model: string, particleCount: number ): ParticlePoint[] { const trajectory: ParticlePoint[] = [] const modelFactors: Record = { 'KOSPS': 0.004, 'POSEIDON': 0.006, 'OpenDrift': 0.005, '앙상블': 0.0055 } const spreadFactor = modelFactors[model] || 0.005 const windSpeed = 5.5 const windDirection = 135 const currentSpeed = 0.55 const currentDirection = 120 const waveHeight = 2.2 const windRadians = (windDirection * Math.PI) / 180 const currentRadians = (currentDirection * Math.PI) / 180 const windWeight = 0.03 const currentWeight = 0.07 const mainDriftLat = Math.sin(windRadians) * windSpeed * windWeight + Math.sin(currentRadians) * currentSpeed * currentWeight const mainDriftLon = Math.cos(windRadians) * windSpeed * windWeight + Math.cos(currentRadians) * currentSpeed * currentWeight const dispersal = waveHeight * 0.001 for (let p = 0; p < particleCount; p++) { const initialSpread = 0.001 const randomAngle = Math.random() * Math.PI * 2 let particleLat = startLat + Math.sin(randomAngle) * initialSpread * Math.random() let particleLon = startLon + Math.cos(randomAngle) * initialSpread * Math.random() for (let h = 0; h <= hours; h++) { const mainMovementLat = mainDriftLat * h * 0.01 const mainMovementLon = mainDriftLon * h * 0.01 const turbulence = Math.sin(h * 0.3 + p * 0.5) * dispersal * h const turbulenceAngle = (h * 0.2 + p * 0.7) * Math.PI trajectory.push({ lat: particleLat + mainMovementLat + Math.sin(turbulenceAngle) * turbulence, lon: particleLon + mainMovementLon + Math.cos(turbulenceAngle) * turbulence, time: h, particle: p }) } } return trajectory } /** * GET /api/simulation/status/:jobId * 시뮬레이션 작업 상태 확인 */ router.get('/status/:jobId', async (req: Request, res: Response) => { const jobId = req.params.jobId as string // jobId 형식 검증 (영숫자, 하이픈만 허용) if (!jobId || !/^[a-zA-Z0-9-]+$/.test(jobId) || jobId.length > 50) { return res.status(400).json({ error: '유효하지 않은 작업 ID' }) } res.json({ jobId: escapeHtml(jobId), status: 'completed', progress: 100, message: 'Simulation completed' }) }) export default router