wing-ops/backend/src/analysis/analysisRouter.ts

167 lines
5.9 KiB
TypeScript

import express from 'express'
import { wingPool } from '../db/wingDb.js'
import { isValidNumber } from '../middleware/security.js'
const router = express.Router()
// ============================================================
// 공간 쿼리 대상 테이블 레지스트리
// 새 테이블 추가 시: TABLE_META에 항목 추가 + LAYER 테이블 DATA_TBL_NM 컬럼에 해당 값 등록
// 예) 'gis.coastal_zone': { geomColumn: 'geom', srid: 4326, properties: ['id','zone_nm','...'] }
// ============================================================
const TABLE_META: Record<string, {
geomColumn: string;
srid: number;
properties: string[];
}> = {
'gis.fshfrm': {
geomColumn: 'geom',
srid: 5179,
properties: [
'gid', 'rgn_nm', 'ctgry_cd', 'admdst_nm', 'fids_se',
'fids_knd', 'fids_mthd', 'farm_knd', 'addr', 'area',
'lcns_no', 'ctpv_nm', 'sgg_nm', 'lcns_bgng_', 'lcns_end_y',
],
},
}
// ============================================================
// POST /api/analysis/spatial-query
// 영역(다각형 또는 원) 내 공간 데이터 조회
// ============================================================
router.post('/spatial-query', async (req, res) => {
try {
const { type, polygon, center, radiusM, layers } = req.body as {
type: 'polygon' | 'circle'
polygon?: Array<{ lat: number; lon: number }>
center?: { lat: number; lon: number }
radiusM?: number
layers?: string[]
}
// 조회할 테이블 결정: 요청에서 전달된 layers 또는 기본값
const requestedLayers: string[] = Array.isArray(layers) && layers.length > 0
? layers
: ['gis.fshfrm']
// DB 화이트리스트 검증 (LAYER.DATA_TBL_NM에 등록된 테이블만 허용)
const { rows: allowedRows } = await wingPool.query<{ data_tbl_nm: string }>(
`SELECT DATA_TBL_NM AS data_tbl_nm FROM LAYER
WHERE DATA_TBL_NM = ANY($1) AND USE_YN = 'Y' AND DEL_YN = 'N'`,
[requestedLayers]
)
// TABLE_META에도 등록되어 있어야 실제 쿼리 가능
const allowedTables = allowedRows
.map(r => r.data_tbl_nm)
.filter(tbl => tbl in TABLE_META)
// LAYER 테이블에 없더라도 TABLE_META에 있고 기본 요청이면 허용 (초기 데이터 미등록 시 fallback)
const finalTables = allowedTables.length > 0
? allowedTables
: requestedLayers.filter(tbl => tbl in TABLE_META)
if (finalTables.length === 0) {
return res.status(400).json({ error: '조회 가능한 레이어가 없습니다.' })
}
const allFeatures: object[] = []
const metaList: Array<{ tableName: string; count: number }> = []
for (const tableName of finalTables) {
const meta = TABLE_META[tableName]
const { geomColumn, srid, properties } = meta
const propColumns = properties.join(', ')
let rows: Array<Record<string, unknown>> = []
if (type === 'polygon') {
if (!Array.isArray(polygon) || polygon.length < 3) {
return res.status(400).json({ error: '다각형 분석에는 최소 3개의 좌표가 필요합니다.' })
}
// 좌표 숫자 검증 후 WKT 조립 (SQL 인젝션 방지)
const validCoords = polygon.filter(p =>
isValidNumber(p.lat, -90, 90) && isValidNumber(p.lon, -180, 180)
)
if (validCoords.length < 3) {
return res.status(400).json({ error: '유효하지 않은 좌표가 포함되어 있습니다.' })
}
// 폴리곤을 닫기 위해 첫 번째 좌표를 마지막에 추가
const coordStr = [...validCoords, validCoords[0]]
.map(p => `${p.lon} ${p.lat}`)
.join(', ')
const wkt = `POLYGON((${coordStr}))`
const { rows: queryRows } = await wingPool.query(
`SELECT ${propColumns},
ST_AsGeoJSON(
ST_SimplifyPreserveTopology(ST_Transform(${geomColumn}, 4326), 0.00001)
) AS geom_geojson
FROM ${tableName}
WHERE ST_Intersects(
${geomColumn},
ST_Transform(ST_GeomFromText($1, 4326), ${srid})
)`,
[wkt]
)
rows = queryRows as Array<Record<string, unknown>>
} else if (type === 'circle') {
if (!center || !isValidNumber(center.lat, -90, 90) || !isValidNumber(center.lon, -180, 180)) {
return res.status(400).json({ error: '원 분석에 유효하지 않은 중심 좌표입니다.' })
}
if (!isValidNumber(radiusM, 1, 10000000)) {
return res.status(400).json({ error: '원 분석에 유효하지 않은 반경 값입니다.' })
}
const { rows: queryRows } = await wingPool.query(
`SELECT ${propColumns},
ST_AsGeoJSON(
ST_SimplifyPreserveTopology(ST_Transform(${geomColumn}, 4326), 0.00001)
) AS geom_geojson
FROM ${tableName}
WHERE ST_DWithin(
${geomColumn},
ST_Transform(ST_SetSRID(ST_MakePoint($1, $2), 4326), ${srid}),
$3
)`,
[center.lon, center.lat, radiusM]
)
rows = queryRows as Array<Record<string, unknown>>
} else {
return res.status(400).json({ error: '지원하지 않는 분석 유형입니다. polygon 또는 circle을 사용하세요.' })
}
const features = rows.map(row => {
const { geom_geojson, ...props } = row
return {
type: 'Feature',
geometry: JSON.parse(String(geom_geojson)),
properties: {
...props,
_tableName: tableName,
},
}
})
allFeatures.push(...features)
metaList.push({ tableName, count: features.length })
}
res.json({
type: 'FeatureCollection',
features: allFeatures,
_meta: metaList,
})
} catch (err) {
console.error('[analysis] 공간 쿼리 오류:', err)
res.status(500).json({ error: '공간 쿼리 처리 중 오류가 발생했습니다.' })
}
})
export default router