import express from 'express' import { wingPool } from '../db/wingDb.js' import { enrichLayerWithMetadata } from '../utils/layerIcons.js' import { sanitizeParams, sanitizeString, isValidNumber, isValidStringLength, } from '../middleware/security.js' import { requireAuth, requireRole } from '../auth/authMiddleware.js' const router = express.Router() interface Layer { cmn_cd: string up_cmn_cd: string | null cmn_cd_full_nm: string cmn_cd_nm: string cmn_cd_level: number clnm: string | null data_tbl_nm: string | null } // DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지) const LAYER_COLUMNS = ` LAYER_CD AS cmn_cd, UP_LAYER_CD AS up_cmn_cd, LAYER_FULL_NM AS cmn_cd_full_nm, LAYER_NM AS cmn_cd_nm, LAYER_LEVEL AS cmn_cd_level, WMS_LAYER_NM AS clnm, DATA_TBL_NM AS data_tbl_nm `.trim() // 조상 중 하나라도 USE_YN='N'이면 제외하는 재귀 CTE // 부모가 비활성화되면 자식도 공개 API에서 제외됨 (상속 방식) const ACTIVE_TREE_CTE = ` WITH RECURSIVE active_tree AS ( SELECT LAYER_CD FROM LAYER WHERE UP_LAYER_CD IS NULL AND USE_YN = 'Y' AND DEL_YN = 'N' UNION ALL SELECT l.LAYER_CD FROM LAYER l JOIN active_tree a ON l.UP_LAYER_CD = a.LAYER_CD WHERE l.USE_YN = 'Y' AND l.DEL_YN = 'N' ) `.trim() // 모든 라우트에 파라미터 살균 적용 router.use(sanitizeParams) // 모든 레이어 조회 router.get('/', async (_req, res) => { try { const { rows } = await wingPool.query( `${ACTIVE_TREE_CTE} SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '레이어 조회 실패' }) } }) // 계층 구조로 변환된 레이어 트리 조회 router.get('/tree/all', async (_req, res) => { try { const { rows } = await wingPool.query( `${ACTIVE_TREE_CTE} SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) const layerMap = new Map() enrichedLayers.forEach(layer => { layerMap.set(layer.cmn_cd, { ...layer, children: [] }) }) const rootLayers: (Layer & { children: Layer[] })[] = [] enrichedLayers.forEach(layer => { const layerNode = layerMap.get(layer.cmn_cd)! if (layer.up_cmn_cd === null) { rootLayers.push(layerNode) } else { const parent = layerMap.get(layer.up_cmn_cd) if (parent) { parent.children.push(layerNode) } } }) res.json(rootLayers) } catch { res.status(500).json({ error: '레이어 트리 조회 실패' }) } }) // WMS 레이어만 조회 router.get('/wms/all', async (_req, res) => { try { const { rows } = await wingPool.query( `${ACTIVE_TREE_CTE} SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND WMS_LAYER_NM IS NOT NULL ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: 'WMS 레이어 조회 실패' }) } }) // 특정 레벨의 레이어만 조회 router.get('/level/:level', async (req, res) => { try { const level = parseInt(req.params.level, 10) if (!isValidNumber(level, 1, 10)) { return res.status(400).json({ error: '유효하지 않은 레벨값', message: '레벨은 1~10 범위의 정수여야 합니다.' }) } const { rows } = await wingPool.query( `${ACTIVE_TREE_CTE} SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND LAYER_LEVEL = $1 ORDER BY LAYER_CD`, [level] ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '레벨별 레이어 조회 실패' }) } }) // 특정 부모의 자식 레이어 조회 router.get('/children/:parentId', async (req, res) => { try { const parentId = req.params.parentId if (!parentId || !isValidStringLength(parentId, 50) || !/^[a-zA-Z0-9_-]+$/.test(parentId)) { return res.status(400).json({ error: '유효하지 않은 부모 ID', message: 'ID는 영숫자, 언더스코어, 하이픈만 허용됩니다.' }) } const sanitizedId = sanitizeString(parentId) const { rows } = await wingPool.query( `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`, [sanitizedId] ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '자식 레이어 조회 실패' }) } }) // 특정 레이어 조회 router.get('/:id', async (req, res) => { try { const id = req.params.id if (!id || !isValidStringLength(id, 50) || !/^[a-zA-Z0-9_-]+$/.test(id)) { return res.status(400).json({ error: '유효하지 않은 레이어 ID', message: 'ID는 영숫자, 언더스코어, 하이픈만 허용됩니다.' }) } const sanitizedId = sanitizeString(id) const { rows } = await wingPool.query( `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1 AND DEL_YN = 'N'`, [sanitizedId] ) if (rows.length === 0) { return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) } const enrichedLayer = enrichLayerWithMetadata(rows[0]) res.json(enrichedLayer) } catch { res.status(500).json({ error: '레이어 조회 실패' }) } }) // ── 관리자 전용 엔드포인트 ────────────────────────────────────── // 전체 레이어 목록 (페이지네이션 + 검색/필터, USE_YN 무관) router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => { try { const page = Math.max(1, parseInt(String(req.query.page ?? '1'), 10) || 1) const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? '10'), 10) || 10)) const offset = (page - 1) * limit const search = sanitizeString(String(req.query.search ?? '')).trim() const useYnFilter = String(req.query.useYn ?? '') // 동적 WHERE 절 구성 (DEL_YN = 'N' 기본 조건) const conditions: string[] = ["DEL_YN = 'N'"] const params: (string | number)[] = [] if (search) { params.push(`%${search}%`) const n = params.length conditions.push(`(LAYER_CD ILIKE $${n} OR LAYER_NM ILIKE $${n} OR LAYER_FULL_NM ILIKE $${n})`) } if (useYnFilter === 'Y' || useYnFilter === 'N') { params.push(useYnFilter) conditions.push(`USE_YN = $${params.length}`) } const rootCd = sanitizeString(String(req.query.rootCd ?? '')).trim() if (rootCd) { if (!/^[a-zA-Z0-9_-]+$/.test(rootCd) || !isValidStringLength(rootCd, 50)) { return res.status(400).json({ error: '유효하지 않은 루트 레이어코드' }) } params.push(`${rootCd}%`) conditions.push(`LAYER_CD LIKE $${params.length}`) } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' // 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET const dataParams = [...params, limit, offset] const limitIdx = dataParams.length - 1 const offsetIdx = dataParams.length const [dataResult, countResult] = await Promise.all([ wingPool.query( `SELECT t.*, p.USE_YN AS "parentUseYn" FROM ( SELECT LAYER_CD AS "layerCd", UP_LAYER_CD AS "upLayerCd", LAYER_FULL_NM AS "layerFullNm", LAYER_NM AS "layerNm", LAYER_LEVEL AS "layerLevel", WMS_LAYER_NM AS "wmsLayerNm", DATA_TBL_NM AS "dataTblNm", USE_YN AS "useYn", SORT_ORD AS "sortOrd", TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" FROM LAYER ${whereClause} ORDER BY LAYER_CD LIMIT $${limitIdx} OFFSET $${offsetIdx} ) t LEFT JOIN LAYER p ON t."upLayerCd" = p.LAYER_CD AND p.DEL_YN = 'N' ORDER BY t."layerCd"`, dataParams ), wingPool.query( `SELECT COUNT(*)::int AS total FROM LAYER ${whereClause}`, params ), ]) const total: number = countResult.rows[0].total res.json({ items: dataResult.rows, total, page, totalPages: Math.ceil(total / limit), }) } catch { res.status(500).json({ error: '레이어 목록 조회 실패' }) } }) // 드롭다운용 레이어 옵션 목록 router.get('/admin/options', requireAuth, requireRole('ADMIN'), async (_req, res) => { try { const { rows } = await wingPool.query( `SELECT LAYER_CD AS "layerCd", LAYER_NM AS "layerNm", LAYER_FULL_NM AS "layerFullNm", LAYER_LEVEL AS "layerLevel" FROM LAYER WHERE DEL_YN = 'N' ORDER BY LAYER_CD` ) res.json(rows) } catch { res.status(500).json({ error: '레이어 옵션 조회 실패' }) } }) // 상위코드 기반 다음 자식 코드 계산 router.get('/admin/next-code', requireAuth, requireRole('ADMIN'), async (req, res) => { const upLayerCd = sanitizeString(String(req.query.upLayerCd ?? '')).trim() if (!upLayerCd || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd) || !isValidStringLength(upLayerCd, 50)) { return res.status(400).json({ error: '유효하지 않은 상위 레이어코드' }) } try { const { rows } = await wingPool.query( `SELECT LAYER_CD AS "layerCd" FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N' ORDER BY LAYER_CD DESC LIMIT 1`, [upLayerCd] ) let nextCode: string if (rows.length === 0) { nextCode = upLayerCd + '001' } else { const lastCd = rows[0].layerCd as string const suffix = lastCd.substring(upLayerCd.length) const num = parseInt(suffix, 10) nextCode = isNaN(num) ? upLayerCd + '001' : upLayerCd + String(num + 1).padStart(suffix.length, '0') } res.json({ nextCode }) } catch { res.status(500).json({ error: '다음 레이어코드 계산 실패' }) } }) // 레이어 생성 router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) => { try { const body = req.body as { layerCd?: string upLayerCd?: string layerFullNm?: string layerNm?: string layerLevel?: number wmsLayerNm?: string dataTblNm?: string useYn?: string sortOrd?: number } const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body // 필수 필드 검증 if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' }) } if (!layerNm || !isValidStringLength(layerNm, 100)) { return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' }) } if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) { return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' }) } if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) { return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' }) } // 선택 필드 검증 if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') { if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) { return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' }) } } if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') { if (!isValidStringLength(wmsLayerNm, 100)) { return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) } } if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') { if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) { return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' }) } } const sanitizedLayerCd = sanitizeString(layerCd) const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const { rows } = await wingPool.query( `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N') RETURNING LAYER_CD AS "layerCd"`, [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd] ) res.json(rows[0]) } catch (err) { const pgErr = err as { code?: string } if (pgErr.code === '23505') { return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' }) } res.status(500).json({ error: '레이어 생성 실패' }) } }) // 레이어 수정 router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) => { try { const body = req.body as { layerCd?: string upLayerCd?: string layerFullNm?: string layerNm?: string layerLevel?: number wmsLayerNm?: string dataTblNm?: string useYn?: string sortOrd?: number } const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body // 필수 필드 검증 if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' }) } if (!layerNm || !isValidStringLength(layerNm, 100)) { return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' }) } if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) { return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' }) } if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) { return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' }) } // 선택 필드 검증 if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') { if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) { return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' }) } } if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') { if (!isValidStringLength(wmsLayerNm, 100)) { return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) } } if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') { if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) { return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' }) } } const sanitizedLayerCd = sanitizeString(layerCd) const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const { rows } = await wingPool.query( `UPDATE LAYER SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5, WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9 WHERE LAYER_CD = $1 RETURNING LAYER_CD AS "layerCd"`, [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd] ) if (rows.length === 0) { return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) } res.json(rows[0]) } catch (err) { const pgErr = err as { code?: string } if (pgErr.code === '23505') { return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' }) } res.status(500).json({ error: '레이어 수정 실패' }) } }) // 레이어 삭제 router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res) => { try { const { layerCd } = req.body as { layerCd?: string } if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { return res.status(400).json({ error: '유효하지 않은 레이어코드입니다.' }) } const sanitizedCd = sanitizeString(layerCd) // 하위 레이어 존재 여부 확인 (자식이 있으면 삭제 차단) const { rows: childRows } = await wingPool.query( `SELECT COUNT(*)::int AS cnt FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'`, [sanitizedCd] ) const childCount: number = childRows[0].cnt if (childCount > 0) { return res.status(400).json({ error: `하위 레이어 ${childCount}개가 있어 삭제할 수 없습니다. 하위 레이어를 먼저 삭제해주세요.`, }) } const { rows } = await wingPool.query( `UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N' RETURNING LAYER_CD AS "layerCd"`, [sanitizedCd] ) if (rows.length === 0) { return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) } res.json(rows[0]) } catch { res.status(500).json({ error: '레이어 삭제 실패' }) } }) // USE_YN 토글 router.post('/admin/toggle-use', requireAuth, requireRole('ADMIN'), async (req, res) => { try { const { layerCd } = req.body as { layerCd?: string } if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { return res.status(400).json({ error: '유효하지 않은 레이어코드' }) } const sanitizedCd = sanitizeString(layerCd) const { rows } = await wingPool.query( `UPDATE LAYER SET USE_YN = CASE WHEN USE_YN = 'Y' THEN 'N' ELSE 'Y' END WHERE LAYER_CD = $1 RETURNING LAYER_CD AS "layerCd", USE_YN AS "useYn"`, [sanitizedCd] ) if (rows.length === 0) { return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) } res.json(rows[0]) } catch { res.status(500).json({ error: 'USE_YN 변경 실패' }) } }) export default router