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 = { '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> = [] 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> } 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> } 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