wing-ops/backend/src/routes/simulation.ts
htlee a0f64e4b11 style: 기존 코드 ESLint/TypeScript 에러 수정
- frontend: ESLint 에러 86건 수정 (unused-vars, set-state-in-effect, static-components 등)
- backend: simulation.ts req.params 타입 단언 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:47:29 +09:00

228 lines
6.4 KiB
TypeScript
Executable File

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<string, number> = {
'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