wing-ops/backend/src/routes/layers.ts
2026-03-24 16:56:03 +09:00

544 lines
20 KiB
TypeScript
Executable File

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<Layer>(
`${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<Layer>(
`${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<string, Layer & { children: Layer[] }>()
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<Layer>(
`${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<Layer>(
`${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<Layer>(
`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<Layer>(
`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