- 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>
228 lines
6.4 KiB
TypeScript
Executable File
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
|