[예측] - OpenDrift Python API 서버 및 스크립트 추가 (prediction/opendrift/) - 시뮬레이션 상태 폴링 훅(useSimulationStatus), 로딩 오버레이 추가 - HydrParticleOverlay: deck.gl 기반 입자 궤적 시각화 레이어 - OilSpillView/LeftPanel/RightPanel: 시뮬레이션 실행·결과 표시 UI 개편 - predictionService/predictionRouter: 시뮬레이션 CRUD 및 상태 관리 API - simulation.ts: OpenDrift 연동 엔드포인트 확장 - docs/PREDICTION-GUIDE.md: 예측 기능 개발 가이드 추가 [CCTV/항공방제] - CCTV 오일 감지 GPU 추론 연동 (OilDetectionOverlay, useOilDetection) - CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지) - oil_inference_server.py: Python GPU 추론 서버 [관리자] - 관리자 화면 고도화 (사용자/권한/게시판/선박신호 패널) - AdminSidebar, BoardMgmtPanel, VesselSignalPanel 신규 컴포넌트 [기타] - DB: 시뮬레이션 결과, 선박보험 시드(1391건), 역할 정리 마이그레이션 - 팀 워크플로우 v1.6.1 동기화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
231 lines
8.6 KiB
TypeScript
231 lines
8.6 KiB
TypeScript
import { Router } from 'express'
|
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
|
import { AuthError } from '../auth/authService.js'
|
|
import {
|
|
listPosts, getPost, createPost, updatePost, deletePost, adminDeletePost,
|
|
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
|
|
} from './boardService.js'
|
|
|
|
const router = Router()
|
|
|
|
// 카테고리 → 리소스 매핑
|
|
const CATEGORY_RESOURCE: Record<string, string> = {
|
|
NOTICE: 'board:notice',
|
|
DATA: 'board:data',
|
|
QNA: 'board:qna',
|
|
MANUAL: 'board:manual',
|
|
}
|
|
|
|
// ============================================================
|
|
// 매뉴얼 라우트 (/:sn 보다 먼저 등록해야 함)
|
|
// ============================================================
|
|
|
|
// GET /api/board/manual — 매뉴얼 목록
|
|
router.get('/manual', requireAuth, requirePermission('board:manual', 'READ'), async (req, res) => {
|
|
try {
|
|
const { category, search } = req.query
|
|
const items = await listManuals({
|
|
category: category as string | undefined,
|
|
search: search as string | undefined,
|
|
})
|
|
res.json(items)
|
|
} catch (err) {
|
|
console.error('[board] 매뉴얼 목록 오류:', err)
|
|
res.status(500).json({ error: '매뉴얼 목록 조회 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// POST /api/board/manual — 매뉴얼 등록
|
|
router.post('/manual', requireAuth, requirePermission('board:manual', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const { catgNm, title, version, fileTp, fileSz, filePath, authorNm } = req.body
|
|
if (!catgNm || !title) {
|
|
res.status(400).json({ error: '카테고리와 제목은 필수입니다.' })
|
|
return
|
|
}
|
|
const result = await createManual({ catgNm, title, version, fileTp, fileSz, filePath, authorNm })
|
|
res.status(201).json(result)
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
|
console.error('[board] 매뉴얼 등록 오류:', err)
|
|
res.status(500).json({ error: '매뉴얼 등록 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// PUT /api/board/manual/:sn — 매뉴얼 수정
|
|
router.put('/manual/:sn', requireAuth, requirePermission('board:manual', 'UPDATE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10)
|
|
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return }
|
|
const { catgNm, title, version, fileTp, fileSz, filePath } = req.body
|
|
await updateManual(sn, { catgNm, title, version, fileTp, fileSz, filePath })
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
|
console.error('[board] 매뉴얼 수정 오류:', err)
|
|
res.status(500).json({ error: '매뉴얼 수정 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// DELETE /api/board/manual/:sn — 매뉴얼 삭제
|
|
router.delete('/manual/:sn', requireAuth, requirePermission('board:manual', 'DELETE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10)
|
|
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return }
|
|
await deleteManual(sn)
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
|
console.error('[board] 매뉴얼 삭제 오류:', err)
|
|
res.status(500).json({ error: '매뉴얼 삭제 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// POST /api/board/manual/:sn/download — 매뉴얼 다운로드 카운트 증가
|
|
router.post('/manual/:sn/download', requireAuth, requirePermission('board:manual', 'READ'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10)
|
|
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return }
|
|
await incrementManualDownload(sn)
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
console.error('[board] 다운로드 카운트 오류:', err)
|
|
res.status(500).json({ error: '다운로드 처리 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// ============================================================
|
|
// 게시글 라우트
|
|
// ============================================================
|
|
|
|
// GET /api/board — 게시글 목록
|
|
router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
|
try {
|
|
const { categoryCd, search, page, size } = req.query
|
|
const result = await listPosts({
|
|
categoryCd: categoryCd as string | undefined,
|
|
search: search as string | undefined,
|
|
page: page ? parseInt(page as string, 10) : undefined,
|
|
size: size ? parseInt(size as string, 10) : undefined,
|
|
})
|
|
res.json(result)
|
|
} catch (err) {
|
|
console.error('[board] 목록 조회 오류:', err)
|
|
res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// GET /api/board/:sn — 게시글 상세
|
|
router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10)
|
|
if (isNaN(sn)) {
|
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
|
return
|
|
}
|
|
const post = await getPost(sn)
|
|
res.json(post)
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message })
|
|
return
|
|
}
|
|
console.error('[board] 상세 조회 오류:', err)
|
|
res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// POST /api/board — 게시글 작성 (카테고리별 CREATE 권한)
|
|
router.post('/', requireAuth, async (req, res, next) => {
|
|
const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board'
|
|
requirePermission(resource, 'CREATE')(req, res, next)
|
|
}, async (req, res) => {
|
|
try {
|
|
const { categoryCd, title, content, pinnedYn } = req.body
|
|
|
|
if (!categoryCd || !title) {
|
|
res.status(400).json({ error: '카테고리와 제목은 필수입니다.' })
|
|
return
|
|
}
|
|
|
|
const result = await createPost({
|
|
categoryCd,
|
|
title,
|
|
content,
|
|
authorId: req.user!.sub,
|
|
pinnedYn,
|
|
})
|
|
res.status(201).json(result)
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message })
|
|
return
|
|
}
|
|
console.error('[board] 작성 오류:', err)
|
|
res.status(500).json({ error: '게시글 작성 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서)
|
|
router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10)
|
|
if (isNaN(sn)) {
|
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
|
return
|
|
}
|
|
|
|
const { title, content, pinnedYn } = req.body
|
|
await updatePost(sn, { title, content, pinnedYn }, req.user!.sub)
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message })
|
|
return
|
|
}
|
|
console.error('[board] 수정 오류:', err)
|
|
res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증)
|
|
router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10)
|
|
if (isNaN(sn)) {
|
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
|
return
|
|
}
|
|
|
|
await deletePost(sn, req.user!.sub)
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message })
|
|
return
|
|
}
|
|
console.error('[board] 삭제 오류:', err)
|
|
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
// POST /api/board/admin-delete — 관리자 전용 게시글 삭제 (소유자 검증 없음)
|
|
router.post('/admin-delete', requireAuth, requirePermission('admin', 'READ'), async (req, res) => {
|
|
try {
|
|
const { sn } = req.body
|
|
const postSn = typeof sn === 'number' ? sn : parseInt(sn, 10)
|
|
if (isNaN(postSn)) {
|
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
|
return
|
|
}
|
|
await adminDeletePost(postSn)
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
|
console.error('[board] 관리자 삭제 오류:', err)
|
|
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
|
|
}
|
|
})
|
|
|
|
export default router
|