167 lines
5.9 KiB
TypeScript
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
|