release: Phase 1~5 리팩토링 통합 릴리즈 #26
137
backend/src/board/boardRouter.ts
Normal file
137
backend/src/board/boardRouter.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
import { listPosts, getPost, createPost, updatePost, deletePost } from './boardService.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 카테고리 → 리소스 매핑
|
||||
const CATEGORY_RESOURCE: Record<string, string> = {
|
||||
NOTICE: 'board:notice',
|
||||
DATA: 'board:data',
|
||||
QNA: 'board:qna',
|
||||
MANUAL: 'board:manual',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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: '게시글 삭제 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
243
backend/src/board/boardService.ts
Normal file
243
backend/src/board/boardService.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { wingPool } from '../db/wingDb.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
|
||||
// ============================================================
|
||||
// 인터페이스
|
||||
// ============================================================
|
||||
|
||||
interface PostListItem {
|
||||
sn: number
|
||||
categoryCd: string
|
||||
title: string
|
||||
authorId: string
|
||||
authorName: string
|
||||
viewCnt: number
|
||||
pinnedYn: string
|
||||
regDtm: string
|
||||
}
|
||||
|
||||
interface PostDetail extends PostListItem {
|
||||
content: string | null
|
||||
mdfcnDtm: string | null
|
||||
}
|
||||
|
||||
interface ListPostsInput {
|
||||
categoryCd?: string
|
||||
search?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ListPostsResult {
|
||||
items: PostListItem[]
|
||||
totalCount: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface CreatePostInput {
|
||||
categoryCd: string
|
||||
title: string
|
||||
content?: string
|
||||
authorId: string
|
||||
pinnedYn?: string
|
||||
}
|
||||
|
||||
interface UpdatePostInput {
|
||||
title?: string
|
||||
content?: string
|
||||
pinnedYn?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRUD 함수
|
||||
// ============================================================
|
||||
|
||||
const VALID_CATEGORIES = ['NOTICE', 'DATA', 'QNA', 'MANUAL']
|
||||
|
||||
export async function listPosts(input: ListPostsInput): Promise<ListPostsResult> {
|
||||
const page = input.page && input.page > 0 ? input.page : 1
|
||||
const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20
|
||||
const offset = (page - 1) * size
|
||||
|
||||
let whereClause = `WHERE bp.USE_YN = 'Y'`
|
||||
const params: (string | number)[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (input.categoryCd) {
|
||||
whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}`
|
||||
params.push(input.categoryCd)
|
||||
}
|
||||
|
||||
if (input.search) {
|
||||
whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})`
|
||||
params.push(`%${input.search}%`)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
// 전체 건수
|
||||
const countResult = await wingPool.query(
|
||||
`SELECT COUNT(*) as cnt
|
||||
FROM BOARD_POST bp
|
||||
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
|
||||
${whereClause}`,
|
||||
params
|
||||
)
|
||||
const totalCount = parseInt(countResult.rows[0].cnt, 10)
|
||||
|
||||
// 목록 (상단고정 우선, 등록일 내림차순)
|
||||
const listParams = [...params, size, offset]
|
||||
const listResult = await wingPool.query(
|
||||
`SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title,
|
||||
bp.AUTHOR_ID as author_id, u.USER_NM as author_name,
|
||||
bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn,
|
||||
bp.REG_DTM as reg_dtm
|
||||
FROM BOARD_POST bp
|
||||
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
|
||||
${whereClause}
|
||||
ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC
|
||||
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
|
||||
listParams
|
||||
)
|
||||
|
||||
const items: PostListItem[] = listResult.rows.map((r: Record<string, unknown>) => ({
|
||||
sn: r.sn as number,
|
||||
categoryCd: r.category_cd as string,
|
||||
title: r.title as string,
|
||||
authorId: r.author_id as string,
|
||||
authorName: r.author_name as string,
|
||||
viewCnt: r.view_cnt as number,
|
||||
pinnedYn: r.pinned_yn as string,
|
||||
regDtm: r.reg_dtm as string,
|
||||
}))
|
||||
|
||||
return { items, totalCount, page, size }
|
||||
}
|
||||
|
||||
export async function getPost(postSn: number): Promise<PostDetail> {
|
||||
// 조회수 증가 + 상세 조회 (단일 쿼리)
|
||||
const result = await wingPool.query(
|
||||
`UPDATE BOARD_POST SET VIEW_CNT = VIEW_CNT + 1
|
||||
WHERE POST_SN = $1 AND USE_YN = 'Y'
|
||||
RETURNING POST_SN as sn, CATEGORY_CD as category_cd, TITLE as title,
|
||||
CONTENT as content, AUTHOR_ID as author_id,
|
||||
VIEW_CNT as view_cnt, PINNED_YN as pinned_yn,
|
||||
REG_DTM as reg_dtm, MDFCN_DTM as mdfcn_dtm`,
|
||||
[postSn]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
|
||||
// 작성자명 조회
|
||||
const authorResult = await wingPool.query(
|
||||
'SELECT USER_NM as name FROM AUTH_USER WHERE USER_ID = $1',
|
||||
[row.author_id]
|
||||
)
|
||||
|
||||
return {
|
||||
sn: row.sn,
|
||||
categoryCd: row.category_cd,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
authorId: row.author_id,
|
||||
authorName: authorResult.rows[0]?.name || '알 수 없음',
|
||||
viewCnt: row.view_cnt,
|
||||
pinnedYn: row.pinned_yn,
|
||||
regDtm: row.reg_dtm,
|
||||
mdfcnDtm: row.mdfcn_dtm,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPost(input: CreatePostInput): Promise<{ sn: number }> {
|
||||
if (!VALID_CATEGORIES.includes(input.categoryCd)) {
|
||||
throw new AuthError('유효하지 않은 카테고리입니다.', 400)
|
||||
}
|
||||
|
||||
if (!input.title || input.title.trim().length === 0) {
|
||||
throw new AuthError('제목은 필수입니다.', 400)
|
||||
}
|
||||
|
||||
const result = await wingPool.query(
|
||||
`INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING POST_SN as sn`,
|
||||
[input.categoryCd, input.title.trim(), input.content || null, input.authorId, input.pinnedYn || 'N']
|
||||
)
|
||||
|
||||
return { sn: result.rows[0].sn }
|
||||
}
|
||||
|
||||
export async function updatePost(
|
||||
postSn: number,
|
||||
input: UpdatePostInput,
|
||||
requesterId: string
|
||||
): Promise<void> {
|
||||
// 게시글 존재 + 작성자 확인
|
||||
const existing = await wingPool.query(
|
||||
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||
[postSn]
|
||||
)
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
if (existing.rows[0].author_id !== requesterId) {
|
||||
throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403)
|
||||
}
|
||||
|
||||
const sets: string[] = []
|
||||
const params: (string | number | null)[] = []
|
||||
let idx = 1
|
||||
|
||||
if (input.title !== undefined) {
|
||||
sets.push(`TITLE = $${idx++}`)
|
||||
params.push(input.title.trim())
|
||||
}
|
||||
if (input.content !== undefined) {
|
||||
sets.push(`CONTENT = $${idx++}`)
|
||||
params.push(input.content)
|
||||
}
|
||||
if (input.pinnedYn !== undefined) {
|
||||
sets.push(`PINNED_YN = $${idx++}`)
|
||||
params.push(input.pinnedYn)
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
throw new AuthError('수정할 항목이 없습니다.', 400)
|
||||
}
|
||||
|
||||
sets.push('MDFCN_DTM = NOW()')
|
||||
params.push(postSn)
|
||||
|
||||
await wingPool.query(
|
||||
`UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
export async function deletePost(postSn: number, requesterId: string): Promise<void> {
|
||||
// 게시글 존재 + 작성자 확인
|
||||
const existing = await wingPool.query(
|
||||
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||
[postSn]
|
||||
)
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
if (existing.rows[0].author_id !== requesterId) {
|
||||
throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403)
|
||||
}
|
||||
|
||||
// 논리 삭제
|
||||
await wingPool.query(
|
||||
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
|
||||
[postSn]
|
||||
)
|
||||
}
|
||||
@ -1,33 +1,13 @@
|
||||
import pg from 'pg'
|
||||
// ============================================================
|
||||
// 하위 호환: authPool → wingPool re-export
|
||||
// DB 통합으로 wing_auth DB가 wing DB의 auth 스키마로 이전됨.
|
||||
// 기존 코드에서 authPool을 import하는 곳에서 에러 없이 동작하도록 유지.
|
||||
// 신규 코드는 wingDb.ts의 wingPool을 직접 import할 것.
|
||||
// ============================================================
|
||||
import { wingPool, testWingDbConnection } from './wingDb.js'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
const authPool = new Pool({
|
||||
host: process.env.AUTH_DB_HOST || 'localhost',
|
||||
port: Number(process.env.AUTH_DB_PORT) || 5432,
|
||||
database: process.env.AUTH_DB_NAME || 'wing_auth',
|
||||
user: process.env.AUTH_DB_USER || 'wing_auth',
|
||||
password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
})
|
||||
|
||||
authPool.on('error', (err) => {
|
||||
console.error('[authDb] 예기치 않은 연결 오류:', err.message)
|
||||
})
|
||||
export const authPool = wingPool
|
||||
|
||||
export async function testAuthDbConnection(): Promise<boolean> {
|
||||
try {
|
||||
const client = await authPool.connect()
|
||||
await client.query('SELECT 1')
|
||||
client.release()
|
||||
console.log('[authDb] wing_auth 데이터베이스 연결 성공')
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[authDb] wing_auth 데이터베이스 연결 실패:', (err as Error).message)
|
||||
return false
|
||||
}
|
||||
return testWingDbConnection()
|
||||
}
|
||||
|
||||
export { authPool }
|
||||
|
||||
@ -2,19 +2,30 @@ import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
// ============================================================
|
||||
// wing DB 통합 Pool (wing 스키마 + auth 스키마)
|
||||
// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등)
|
||||
// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
|
||||
// - public 스키마: PostGIS 시스템 테이블만 유지
|
||||
// ============================================================
|
||||
const wingPool = new Pool({
|
||||
host: process.env.WING_DB_HOST || 'localhost',
|
||||
port: Number(process.env.WING_DB_PORT) || 5432,
|
||||
database: process.env.WING_DB_NAME || 'wing',
|
||||
user: process.env.WING_DB_USER || 'wing',
|
||||
password: process.env.WING_DB_PASSWORD || 'Wing2026',
|
||||
max: 10,
|
||||
host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432,
|
||||
database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing',
|
||||
user: process.env.DB_USER || process.env.WING_DB_USER || 'wing',
|
||||
password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
})
|
||||
|
||||
// 연결 시 search_path 자동 설정 (public 미사용)
|
||||
wingPool.on('connect', (client) => {
|
||||
client.query('SET search_path = wing, auth, public')
|
||||
})
|
||||
|
||||
wingPool.on('error', (err) => {
|
||||
console.error('[wingDb] 예기치 않은 연결 오류:', err.message)
|
||||
console.error('[db] 예기치 않은 연결 오류:', err.message)
|
||||
})
|
||||
|
||||
export async function testWingDbConnection(): Promise<boolean> {
|
||||
@ -22,10 +33,10 @@ export async function testWingDbConnection(): Promise<boolean> {
|
||||
const client = await wingPool.connect()
|
||||
await client.query('SELECT 1')
|
||||
client.release()
|
||||
console.log('[wingDb] wing 데이터베이스 연결 성공')
|
||||
console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)')
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[wingDb] wing 데이터베이스 연결 실패:', (err as Error).message)
|
||||
console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import { testAuthDbConnection } from './db/authDb.js'
|
||||
import { testWingDbConnection } from './db/wingDb.js'
|
||||
import layersRouter from './routes/layers.js'
|
||||
import simulationRouter from './routes/simulation.js'
|
||||
@ -14,6 +13,7 @@ import roleRouter from './roles/roleRouter.js'
|
||||
import settingsRouter from './settings/settingsRouter.js'
|
||||
import menuRouter from './menus/menuRouter.js'
|
||||
import auditRouter from './audit/auditRouter.js'
|
||||
import boardRouter from './board/boardRouter.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
@ -136,6 +136,7 @@ app.use('/api/menus', menuRouter)
|
||||
app.use('/api/audit', auditRouter)
|
||||
|
||||
// API 라우트 — 업무
|
||||
app.use('/api/board', boardRouter)
|
||||
app.use('/api/layers', layersRouter)
|
||||
app.use('/api/simulation', simulationLimiter, simulationRouter)
|
||||
|
||||
@ -173,17 +174,13 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||
|
||||
// wing DB (운영 데이터) 연결 확인
|
||||
await testWingDbConnection()
|
||||
|
||||
// wing_auth DB (인증 데이터) 연결 확인
|
||||
const connected = await testAuthDbConnection()
|
||||
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||
const connected = await testWingDbConnection()
|
||||
if (connected) {
|
||||
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
|
||||
try {
|
||||
const { authPool } = await import('./db/authDb.js')
|
||||
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
|
||||
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
|
||||
const { wingPool } = await import('./db/wingDb.js')
|
||||
await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
|
||||
} catch {
|
||||
// 이미 TEXT이거나 권한 없으면 무시
|
||||
}
|
||||
|
||||
45
database/migration/005_db_consolidation.sql
Normal file
45
database/migration/005_db_consolidation.sql
Normal file
@ -0,0 +1,45 @@
|
||||
-- ============================================================
|
||||
-- 마이그레이션 005: DB 통합 (wing + wing_auth → wing 단일 DB)
|
||||
--
|
||||
-- 스키마 구조:
|
||||
-- wing — 운영 데이터 (LAYER, BOARD_POST, HNS_SUBSTANCE 등)
|
||||
-- auth — 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
|
||||
-- public — PostGIS 시스템 테이블만 유지 (spatial_ref_sys)
|
||||
--
|
||||
-- 실행 순서:
|
||||
-- 1. 이 SQL을 wing DB에서 실행 (스키마 생성 + 테이블 이동)
|
||||
-- 2. wing_auth DB 덤프 → auth 스키마로 복원 (별도 쉘)
|
||||
-- 3. search_path 설정
|
||||
-- ============================================================
|
||||
|
||||
-- Step 1: 명시적 스키마 생성
|
||||
CREATE SCHEMA IF NOT EXISTS wing;
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- Step 2: 기존 public 운영 테이블을 wing 스키마로 이동
|
||||
-- (PostGIS 시스템 테이블 spatial_ref_sys, topology는 public에 유지)
|
||||
ALTER TABLE IF EXISTS public.layer SET SCHEMA wing;
|
||||
ALTER TABLE IF EXISTS public.hns_substance SET SCHEMA wing;
|
||||
|
||||
-- Step 3: 기본 search_path 설정 (DB 레벨)
|
||||
-- wing 사용자가 스키마 접두사 없이 양쪽 테이블 접근 가능
|
||||
ALTER DATABASE wing SET search_path = wing, auth;
|
||||
|
||||
-- Step 4: wing 사용자에게 auth 스키마 권한 부여
|
||||
-- (wing_auth 데이터 복원 후 적용)
|
||||
GRANT USAGE ON SCHEMA auth TO wing;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO wing;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO wing;
|
||||
|
||||
-- Step 5: wing 스키마 기본 권한
|
||||
GRANT ALL PRIVILEGES ON SCHEMA wing TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON TABLES TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON SEQUENCES TO wing;
|
||||
|
||||
-- 검증
|
||||
SELECT schemaname, tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname IN ('wing', 'auth')
|
||||
ORDER BY schemaname, tablename;
|
||||
61
database/migration/006_board.sql
Normal file
61
database/migration/006_board.sql
Normal file
@ -0,0 +1,61 @@
|
||||
-- ============================================================
|
||||
-- 마이그레이션 006: 게시판 (BOARD_POST)
|
||||
-- wing 스키마에 생성, auth.AUTH_USER FK 참조
|
||||
-- ============================================================
|
||||
|
||||
-- Step 1: 게시판 테이블
|
||||
CREATE TABLE IF NOT EXISTS BOARD_POST (
|
||||
POST_SN SERIAL PRIMARY KEY,
|
||||
CATEGORY_CD VARCHAR(20) NOT NULL,
|
||||
TITLE VARCHAR(200) NOT NULL,
|
||||
CONTENT TEXT,
|
||||
AUTHOR_ID UUID NOT NULL,
|
||||
VIEW_CNT INTEGER NOT NULL DEFAULT 0,
|
||||
PINNED_YN CHAR(1) NOT NULL DEFAULT 'N',
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
MDFCN_DTM TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID)
|
||||
REFERENCES auth.AUTH_USER(USER_ID),
|
||||
CONSTRAINT CK_BOARD_CATEGORY
|
||||
CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')),
|
||||
CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')),
|
||||
CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE BOARD_POST IS '게시판 게시글';
|
||||
COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼';
|
||||
COMMENT ON COLUMN BOARD_POST.PINNED_YN IS '상단고정 여부';
|
||||
COMMENT ON COLUMN BOARD_POST.USE_YN IS '사용여부 (N=논리삭제)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD);
|
||||
CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID);
|
||||
CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC);
|
||||
|
||||
-- Step 2: 초기 데이터 (기존 프론트엔드 mockPosts 이전)
|
||||
-- admin 사용자 ID 조회
|
||||
DO $$
|
||||
DECLARE
|
||||
v_admin_id UUID;
|
||||
BEGIN
|
||||
SELECT USER_ID INTO v_admin_id FROM auth.AUTH_USER WHERE USER_ACNT = 'admin' LIMIT 1;
|
||||
|
||||
IF v_admin_id IS NOT NULL THEN
|
||||
INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, VIEW_CNT, PINNED_YN, REG_DTM) VALUES
|
||||
('NOTICE', '시스템 업데이트 안내', '시스템 업데이트 관련 안내사항입니다.', v_admin_id, 245, 'Y', '2025-02-15'::timestamptz),
|
||||
('NOTICE', '2025년 방제 교육 일정 안내', '2025년도 방제 교육 일정을 안내합니다.', v_admin_id, 189, 'Y', '2025-02-14'::timestamptz),
|
||||
('DATA', '방제 매뉴얼 업데이트 (2025년 개정판)', '2025년 개정판 방제 매뉴얼입니다.', v_admin_id, 423, 'N', '2025-02-10'::timestamptz),
|
||||
('QNA', 'HNS 대기확산 분석 결과 해석 문의', 'HNS 분석 결과 해석 방법을 문의합니다.', v_admin_id, 156, 'N', '2025-02-08'::timestamptz),
|
||||
('DATA', '2024년 유류오염사고 통계 자료', '2024년도 유류오염사고 통계 자료를 공유합니다.', v_admin_id, 312, 'N', '2025-02-05'::timestamptz),
|
||||
('QNA', '유출유 확산 예측 알고리즘 선택 기준', '확산 예측 시 알고리즘 선택 기준을 문의합니다.', v_admin_id, 267, 'N', '2025-02-03'::timestamptz),
|
||||
('DATA', '해양오염 방제 장비 운용 가이드', '방제 장비 운용 가이드 문서입니다.', v_admin_id, 534, 'N', '2025-01-28'::timestamptz),
|
||||
('QNA', 'SCAT 조사 방법 관련 질문', 'SCAT 현장 조사 방법에 대해 질문합니다.', v_admin_id, 198, 'N', '2025-01-25'::timestamptz),
|
||||
('DATA', 'HNS 물질 안전보건자료 (MSDS) 모음', 'HNS 물질별 MSDS 자료 모음입니다.', v_admin_id, 645, 'N', '2025-01-20'::timestamptz),
|
||||
('QNA', '항공촬영 드론 운용 시 주의사항', '드론 운용 시 주의할 점을 문의합니다.', v_admin_id, 221, 'N', '2025-01-15'::timestamptz)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 검증
|
||||
SELECT POST_SN, CATEGORY_CD, TITLE, VIEW_CNT, PINNED_YN FROM BOARD_POST ORDER BY POST_SN;
|
||||
@ -338,6 +338,8 @@ const mutation = useMutation({
|
||||
|
||||
## 6. 백엔드 API CRUD 규칙
|
||||
|
||||
> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조
|
||||
|
||||
### HTTP Method 정책 (보안 가이드 준수)
|
||||
- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다.
|
||||
- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환).
|
||||
|
||||
1433
docs/CRUD-API-GUIDE.md
Normal file
1433
docs/CRUD-API-GUIDE.md
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,247 +1,245 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuthStore } from '@common/store/authStore';
|
||||
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
|
||||
|
||||
interface BoardPost {
|
||||
id: number
|
||||
category: string
|
||||
title: string
|
||||
author: string
|
||||
date: string
|
||||
views: number
|
||||
isNotice?: boolean
|
||||
}
|
||||
// 카테고리 코드 ↔ 표시명 매핑
|
||||
const CATEGORY_MAP: Record<string, string> = {
|
||||
NOTICE: '공지사항',
|
||||
DATA: '자료실',
|
||||
QNA: 'Q&A',
|
||||
MANUAL: '해경매뉴얼',
|
||||
};
|
||||
|
||||
const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
||||
{ label: '전체', code: null },
|
||||
{ label: '공지사항', code: 'NOTICE' },
|
||||
{ label: '자료실', code: 'DATA' },
|
||||
{ label: 'Q&A', code: 'QNA' },
|
||||
];
|
||||
|
||||
const CATEGORY_STYLE: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-blue-500/20 text-blue-400',
|
||||
QNA: 'bg-green-500/20 text-green-400',
|
||||
MANUAL: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface BoardListTableProps {
|
||||
onPostClick: (id: number) => void
|
||||
onWriteClick: () => void
|
||||
onPostClick: (id: number) => void;
|
||||
onWriteClick: () => void;
|
||||
}
|
||||
|
||||
const mockPosts: BoardPost[] = [
|
||||
{
|
||||
id: 1,
|
||||
category: '공지사항',
|
||||
title: '시스템 업데이트 안내',
|
||||
author: '관리자',
|
||||
date: '2025-02-15',
|
||||
views: 245,
|
||||
isNotice: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: '공지사항',
|
||||
title: '2025년 방제 교육 일정 안내',
|
||||
author: '관리자',
|
||||
date: '2025-02-14',
|
||||
views: 189,
|
||||
isNotice: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: '자료실',
|
||||
title: '방제 매뉴얼 업데이트 (2025년 개정판)',
|
||||
author: '김철수',
|
||||
date: '2025-02-10',
|
||||
views: 423
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: 'Q&A',
|
||||
title: 'HNS 대기확산 분석 결과 해석 문의',
|
||||
author: '이영희',
|
||||
date: '2025-02-08',
|
||||
views: 156
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: '자료실',
|
||||
title: '2024년 유류오염사고 통계 자료',
|
||||
author: '박민수',
|
||||
date: '2025-02-05',
|
||||
views: 312
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
category: 'Q&A',
|
||||
title: '유출유 확산 예측 알고리즘 선택 기준',
|
||||
author: '정수진',
|
||||
date: '2025-02-03',
|
||||
views: 267
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
category: '자료실',
|
||||
title: '해양오염 방제 장비 운용 가이드',
|
||||
author: '최동현',
|
||||
date: '2025-01-28',
|
||||
views: 534
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
category: 'Q&A',
|
||||
title: 'SCAT 조사 방법 관련 질문',
|
||||
author: '강지은',
|
||||
date: '2025-01-25',
|
||||
views: 198
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
category: '자료실',
|
||||
title: 'HNS 물질 안전보건자료 (MSDS) 모음',
|
||||
author: '윤성호',
|
||||
date: '2025-01-20',
|
||||
views: 645
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
category: 'Q&A',
|
||||
title: '항공촬영 드론 운용 시 주의사항',
|
||||
author: '송미래',
|
||||
date: '2025-01-15',
|
||||
views: 221
|
||||
}
|
||||
]
|
||||
|
||||
export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('전체')
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission);
|
||||
|
||||
const categories = ['전체', '공지사항', '자료실', 'Q&A']
|
||||
const [posts, setPosts] = useState<BoardPostItem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const filteredPosts = mockPosts.filter((post) => {
|
||||
const matchesCategory = selectedCategory === '전체' || post.category === selectedCategory
|
||||
const matchesSearch =
|
||||
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
post.author.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||
|
||||
// 카테고리별 서브리소스 권한 확인 (전체 선택 시 board CREATE)
|
||||
const canWrite = selectedCategory
|
||||
? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE')
|
||||
: hasPermission('board', 'CREATE');
|
||||
|
||||
const loadPosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchBoardPosts({
|
||||
categoryCd: selectedCategory || undefined,
|
||||
search: searchTerm || undefined,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
setPosts(result.items);
|
||||
setTotalCount(result.totalCount);
|
||||
} catch {
|
||||
setPosts([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCategory, searchTerm, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
const handleCategoryChange = (code: string | null) => {
|
||||
setSelectedCategory(code);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchTerm(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
|
||||
const formatDate = (dtm: string) => {
|
||||
return new Date(dtm).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* Header with Search and Write Button */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Category Filters */}
|
||||
<div className="flex gap-2">
|
||||
{categories.map((category) => (
|
||||
{CATEGORY_FILTER.map((cat) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
key={cat.label}
|
||||
onClick={() => handleCategoryChange(cat.code)}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
||||
selectedCategory === category
|
||||
selectedCategory === cat.code
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="제목, 작성자 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
/>
|
||||
|
||||
{/* Write Button */}
|
||||
<button
|
||||
onClick={onWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
>
|
||||
<span>✏️</span>
|
||||
<span>글쓰기</span>
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={onWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>글쓰기</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board List Table */}
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPosts.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
onClick={() => onPostClick(post.id)}
|
||||
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1">
|
||||
{post.isNotice ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
</span>
|
||||
) : (
|
||||
post.id
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
post.category === '공지사항'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: post.category === '자료실'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-green-500/20 text-green-400'
|
||||
}`}
|
||||
>
|
||||
{post.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.isNotice ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
} hover:text-primary-cyan transition-colors`}
|
||||
>
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2">{post.author}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.date}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.views}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredPosts.length === 0 && (
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
||||
<p className="text-text-3 text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr
|
||||
key={post.sn}
|
||||
onClick={() => onPostClick(post.sn)}
|
||||
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1">
|
||||
{post.pinnedYn === 'Y' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
</span>
|
||||
) : (
|
||||
post.sn
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
} hover:text-primary-cyan transition-colors`}
|
||||
>
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
이전
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-primary-cyan text-bg-0 font-semibold">
|
||||
1
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
2
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
3
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded ${
|
||||
page === p
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
75
frontend/src/tabs/board/services/boardApi.ts
Normal file
75
frontend/src/tabs/board/services/boardApi.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
// ============================================================
|
||||
// 인터페이스
|
||||
// ============================================================
|
||||
|
||||
export interface BoardPostItem {
|
||||
sn: number;
|
||||
categoryCd: string;
|
||||
title: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
viewCnt: number;
|
||||
pinnedYn: string;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
export interface BoardPostDetail extends BoardPostItem {
|
||||
content: string | null;
|
||||
mdfcnDtm: string | null;
|
||||
}
|
||||
|
||||
export interface BoardListResponse {
|
||||
items: BoardPostItem[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BoardListParams {
|
||||
categoryCd?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface CreateBoardPostInput {
|
||||
categoryCd: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
pinnedYn?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBoardPostInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
pinnedYn?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 함수
|
||||
// ============================================================
|
||||
|
||||
export async function fetchBoardPosts(params?: BoardListParams): Promise<BoardListResponse> {
|
||||
const response = await api.get<BoardListResponse>('/board', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchBoardPost(sn: number): Promise<BoardPostDetail> {
|
||||
const response = await api.get<BoardPostDetail>(`/board/${sn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> {
|
||||
const response = await api.post<{ sn: number }>('/board', input);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateBoardPost(sn: number, input: UpdateBoardPostInput): Promise<void> {
|
||||
await api.put(`/board/${sn}`, input);
|
||||
}
|
||||
|
||||
export async function deleteBoardPost(sn: number): Promise<void> {
|
||||
await api.delete(`/board/${sn}`);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user