Merge pull request 'release: 2026-02-28 (2건 커밋)' (#18) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m27s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m27s
Reviewed-on: #18
This commit is contained in:
커밋
1d5ed728d2
@ -90,6 +90,11 @@ wing/
|
|||||||
- `naming.md` — 네이밍 규칙
|
- `naming.md` — 네이밍 규칙
|
||||||
- `testing.md` — 테스트 규칙
|
- `testing.md` — 테스트 규칙
|
||||||
|
|
||||||
|
## 공통 기능 문서
|
||||||
|
- `docs/COMMON-GUIDE.md` — 공통 로직 개발 가이드 (인증, 감사로그, 메뉴, API 등)
|
||||||
|
- 공통 기능(인증, 감사로그, 메뉴 시스템, API 통신 등)을 추가/변경할 때 반드시 `docs/COMMON-GUIDE.md`를 최신화할 것
|
||||||
|
- 개별 탭 개발자는 이 문서를 참조하여 공통 영역과의 연동을 구현
|
||||||
|
|
||||||
## 환경 설정
|
## 환경 설정
|
||||||
- Node.js 20 (`.node-version`, fnm 사용)
|
- Node.js 20 (`.node-version`, fnm 사용)
|
||||||
- npm registry: Nexus proxy (`.npmrc`)
|
- npm registry: Nexus proxy (`.npmrc`)
|
||||||
|
|||||||
74
backend/src/audit/auditRouter.ts
Normal file
74
backend/src/audit/auditRouter.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
|
import { insertAuditLog, listAuditLogs } from './auditService.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// POST /api/audit/log — sendBeacon 수신 (탭 이동 등 클라이언트 감사 로그)
|
||||||
|
router.post('/log', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// sendBeacon은 Content-Type: text/plain으로 전송하므로 body를 직접 파싱
|
||||||
|
let body = req.body
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(body)
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ error: '잘못된 요청 형식입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, detail } = body as { action?: string; detail?: string }
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
res.status(400).json({ error: 'action 필드는 필수입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddr = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || ''
|
||||||
|
const userAgent = req.headers['user-agent'] || ''
|
||||||
|
|
||||||
|
await insertAuditLog({
|
||||||
|
userId: req.user!.sub,
|
||||||
|
actionCd: action,
|
||||||
|
actionDtl: detail,
|
||||||
|
ipAddr,
|
||||||
|
userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[audit] 감사 로그 기록 오류:', err)
|
||||||
|
res.status(500).json({ error: '감사 로그 기록 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/audit/logs — 관리자용 감사 로그 조회
|
||||||
|
router.get('/logs', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page, size, userId, actionCd, from, to } = req.query as {
|
||||||
|
page?: string
|
||||||
|
size?: string
|
||||||
|
userId?: string
|
||||||
|
actionCd?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await listAuditLogs({
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
size: size ? parseInt(size, 10) : undefined,
|
||||||
|
userId,
|
||||||
|
actionCd,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[audit] 감사 로그 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '감사 로그 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
138
backend/src/audit/auditService.ts
Normal file
138
backend/src/audit/auditService.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { authPool } from '../db/authDb.js'
|
||||||
|
|
||||||
|
interface InsertAuditLogInput {
|
||||||
|
userId: string
|
||||||
|
actionCd: string
|
||||||
|
actionDtl?: string
|
||||||
|
httpMethod?: string
|
||||||
|
crudType?: string
|
||||||
|
reqUrl?: string
|
||||||
|
resStatus?: number
|
||||||
|
resSize?: number
|
||||||
|
ipAddr?: string
|
||||||
|
userAgent?: string
|
||||||
|
extra?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogItem {
|
||||||
|
logSn: number
|
||||||
|
userId: string
|
||||||
|
userName: string | null
|
||||||
|
userAccount: string | null
|
||||||
|
actionCd: string
|
||||||
|
actionDtl: string | null
|
||||||
|
httpMethod: string | null
|
||||||
|
crudType: string | null
|
||||||
|
reqUrl: string | null
|
||||||
|
reqDtm: string
|
||||||
|
resDtm: string | null
|
||||||
|
resStatus: number | null
|
||||||
|
resSize: number | null
|
||||||
|
ipAddr: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
extra: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogListResult {
|
||||||
|
items: AuditLogItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertAuditLog(input: InsertAuditLogInput): Promise<void> {
|
||||||
|
await authPool.query(
|
||||||
|
`INSERT INTO AUTH_AUDIT_LOG
|
||||||
|
(USER_ID, ACTION_CD, ACTION_DTL, HTTP_METHOD, CRUD_TYPE, REQ_URL,
|
||||||
|
RES_STATUS, RES_SIZE, IP_ADDR, USER_AGENT, EXTRA)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
|
[
|
||||||
|
input.userId,
|
||||||
|
input.actionCd,
|
||||||
|
input.actionDtl || null,
|
||||||
|
input.httpMethod || null,
|
||||||
|
input.crudType || null,
|
||||||
|
input.reqUrl || null,
|
||||||
|
input.resStatus || null,
|
||||||
|
input.resSize || null,
|
||||||
|
input.ipAddr || null,
|
||||||
|
input.userAgent || null,
|
||||||
|
input.extra ? JSON.stringify(input.extra) : null,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuditLogs(filters?: {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
userId?: string
|
||||||
|
actionCd?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}): Promise<AuditLogListResult> {
|
||||||
|
const page = filters?.page || 1
|
||||||
|
const size = Math.min(filters?.size || 50, 200)
|
||||||
|
const offset = (page - 1) * size
|
||||||
|
|
||||||
|
let whereClause = 'WHERE 1=1'
|
||||||
|
const params: (string | number)[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
if (filters?.userId) {
|
||||||
|
whereClause += ` AND a.USER_ID = $${paramIdx++}`
|
||||||
|
params.push(filters.userId)
|
||||||
|
}
|
||||||
|
if (filters?.actionCd) {
|
||||||
|
whereClause += ` AND a.ACTION_CD = $${paramIdx++}`
|
||||||
|
params.push(filters.actionCd)
|
||||||
|
}
|
||||||
|
if (filters?.from) {
|
||||||
|
whereClause += ` AND a.REQ_DTM >= $${paramIdx++}`
|
||||||
|
params.push(filters.from)
|
||||||
|
}
|
||||||
|
if (filters?.to) {
|
||||||
|
whereClause += ` AND a.REQ_DTM <= $${paramIdx++}`
|
||||||
|
params.push(filters.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await authPool.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM AUTH_AUDIT_LOG a ${whereClause}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
const total = parseInt(countResult.rows[0].cnt, 10)
|
||||||
|
|
||||||
|
const dataParams = [...params, size, offset]
|
||||||
|
const result = await authPool.query(
|
||||||
|
`SELECT a.LOG_SN, a.USER_ID, u.USER_NM, u.USER_ACNT,
|
||||||
|
a.ACTION_CD, a.ACTION_DTL, a.HTTP_METHOD, a.CRUD_TYPE,
|
||||||
|
a.REQ_URL, a.REQ_DTM, a.RES_DTM, a.RES_STATUS, a.RES_SIZE,
|
||||||
|
a.IP_ADDR, a.USER_AGENT, a.EXTRA
|
||||||
|
FROM AUTH_AUDIT_LOG a
|
||||||
|
LEFT JOIN AUTH_USER u ON a.USER_ID = u.USER_ID
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.REQ_DTM DESC
|
||||||
|
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
|
||||||
|
dataParams
|
||||||
|
)
|
||||||
|
|
||||||
|
const items: AuditLogItem[] = result.rows.map((row: Record<string, unknown>) => ({
|
||||||
|
logSn: row.log_sn as number,
|
||||||
|
userId: row.user_id as string,
|
||||||
|
userName: row.user_nm as string | null,
|
||||||
|
userAccount: row.user_acnt as string | null,
|
||||||
|
actionCd: row.action_cd as string,
|
||||||
|
actionDtl: row.action_dtl as string | null,
|
||||||
|
httpMethod: row.http_method as string | null,
|
||||||
|
crudType: row.crud_type as string | null,
|
||||||
|
reqUrl: row.req_url as string | null,
|
||||||
|
reqDtm: row.req_dtm as string,
|
||||||
|
resDtm: row.res_dtm as string | null,
|
||||||
|
resStatus: row.res_status as number | null,
|
||||||
|
resSize: row.res_size as number | null,
|
||||||
|
ipAddr: row.ip_addr as string | null,
|
||||||
|
userAgent: row.user_agent as string | null,
|
||||||
|
extra: row.extra as Record<string, unknown> | null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { items, total, page, size }
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import userRouter from './users/userRouter.js'
|
|||||||
import roleRouter from './roles/roleRouter.js'
|
import roleRouter from './roles/roleRouter.js'
|
||||||
import settingsRouter from './settings/settingsRouter.js'
|
import settingsRouter from './settings/settingsRouter.js'
|
||||||
import menuRouter from './menus/menuRouter.js'
|
import menuRouter from './menus/menuRouter.js'
|
||||||
|
import auditRouter from './audit/auditRouter.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
sanitizeQuery,
|
sanitizeQuery,
|
||||||
@ -105,6 +106,7 @@ app.use(cookieParser())
|
|||||||
|
|
||||||
// 6. JSON 본문 파서 (크기 제한 적용)
|
// 6. JSON 본문 파서 (크기 제한 적용)
|
||||||
app.use(express.json({ limit: BODY_SIZE_LIMIT }))
|
app.use(express.json({ limit: BODY_SIZE_LIMIT }))
|
||||||
|
app.use(express.text({ limit: BODY_SIZE_LIMIT }))
|
||||||
app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT }))
|
app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT }))
|
||||||
|
|
||||||
// 7. 입력값 살균 미들웨어
|
// 7. 입력값 살균 미들웨어
|
||||||
@ -135,6 +137,7 @@ app.use('/api/users', userRouter)
|
|||||||
app.use('/api/roles', roleRouter)
|
app.use('/api/roles', roleRouter)
|
||||||
app.use('/api/settings', settingsRouter)
|
app.use('/api/settings', settingsRouter)
|
||||||
app.use('/api/menus', menuRouter)
|
app.use('/api/menus', menuRouter)
|
||||||
|
app.use('/api/audit', auditRouter)
|
||||||
|
|
||||||
// API 라우트 — 업무
|
// API 라우트 — 업무
|
||||||
app.use('/api/layers', layersRouter)
|
app.use('/api/layers', layersRouter)
|
||||||
|
|||||||
@ -192,6 +192,44 @@ COMMENT ON COLUMN AUTH_SETTING.SETTING_DC IS '설정설명';
|
|||||||
COMMENT ON COLUMN AUTH_SETTING.MDFCN_DTM IS '수정일시';
|
COMMENT ON COLUMN AUTH_SETTING.MDFCN_DTM IS '수정일시';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 8-2. 감사 로그 (AUTH_AUDIT_LOG)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE AUTH_AUDIT_LOG (
|
||||||
|
LOG_SN SERIAL NOT NULL,
|
||||||
|
USER_ID UUID,
|
||||||
|
ACTION_CD VARCHAR(30) NOT NULL,
|
||||||
|
ACTION_DTL VARCHAR(100),
|
||||||
|
HTTP_METHOD VARCHAR(10),
|
||||||
|
CRUD_TYPE VARCHAR(10),
|
||||||
|
REQ_URL VARCHAR(500),
|
||||||
|
REQ_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
RES_DTM TIMESTAMPTZ,
|
||||||
|
RES_STATUS SMALLINT,
|
||||||
|
RES_SIZE INTEGER,
|
||||||
|
IP_ADDR VARCHAR(45),
|
||||||
|
USER_AGENT VARCHAR(500),
|
||||||
|
EXTRA JSONB,
|
||||||
|
CONSTRAINT PK_AUTH_AUDIT_LOG PRIMARY KEY (LOG_SN)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE AUTH_AUDIT_LOG IS '감사로그';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.LOG_SN IS '로그순번';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.USER_ID IS '사용자아이디';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.ACTION_CD IS '액션코드 (TAB_VIEW, API_CALL, LOGIN, LOGOUT, ADMIN_ACTION)';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.ACTION_DTL IS '액션상세 (탭ID, API경로, 관리자작업명)';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.HTTP_METHOD IS 'HTTP메소드 (GET, POST, PUT, DELETE)';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.CRUD_TYPE IS 'CRUD구분 (SELECT, INSERT, UPDATE, DELETE)';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.REQ_URL IS '요청URL';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.REQ_DTM IS '요청일시';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.RES_DTM IS '응답일시';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.RES_STATUS IS '응답HTTP상태코드';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.RES_SIZE IS '응답데이터크기(bytes)';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.IP_ADDR IS 'IP주소';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.USER_AGENT IS '유저에이전트';
|
||||||
|
COMMENT ON COLUMN AUTH_AUDIT_LOG.EXTRA IS '추가메타데이터(JSON)';
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 9. 인덱스
|
-- 9. 인덱스
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@ -203,6 +241,9 @@ CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN);
|
|||||||
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD);
|
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD);
|
||||||
CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID);
|
CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID);
|
||||||
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
|
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
|
||||||
|
CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID);
|
||||||
|
CREATE INDEX IDX_AUDIT_LOG_ACTION ON AUTH_AUDIT_LOG (ACTION_CD);
|
||||||
|
CREATE INDEX IDX_AUDIT_LOG_DTM ON AUTH_AUDIT_LOG (REQ_DTM);
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
326
docs/COMMON-GUIDE.md
Normal file
326
docs/COMMON-GUIDE.md
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
# WING-OPS 공통 로직 개발 가이드
|
||||||
|
|
||||||
|
개별 탭 개발자가 공통 영역 구현을 참조하여 연동할 수 있도록 정리한 문서입니다.
|
||||||
|
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 인증/인가
|
||||||
|
|
||||||
|
### 개요
|
||||||
|
JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다.
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
|
||||||
|
#### 미들웨어 적용
|
||||||
|
```typescript
|
||||||
|
// backend/src/auth/authMiddleware.ts
|
||||||
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
|
// 인증만 필요한 라우트
|
||||||
|
router.use(requireAuth)
|
||||||
|
|
||||||
|
// 특정 역할 필요
|
||||||
|
router.use(requireRole('ADMIN'))
|
||||||
|
router.use(requireRole('ADMIN', 'MANAGER'))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JWT 페이로드 (req.user)
|
||||||
|
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
||||||
|
```typescript
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: string // 사용자 UUID (USER_ID)
|
||||||
|
acnt: string // 계정명 (USER_ACNT)
|
||||||
|
name: string // 사용자명 (USER_NM)
|
||||||
|
roles: string[] // 역할 코드 목록 (ADMIN, MANAGER, USER, VIEWER)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 라우터 패턴
|
||||||
|
```typescript
|
||||||
|
// backend/src/[모듈]/[모듈]Router.ts
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(requireAuth)
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.sub
|
||||||
|
// 비즈니스 로직...
|
||||||
|
res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[모듈] 오류:', err)
|
||||||
|
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
|
||||||
|
#### authStore (Zustand)
|
||||||
|
```typescript
|
||||||
|
// frontend/src/store/authStore.ts
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
|
||||||
|
// 컴포넌트 내에서 사용
|
||||||
|
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
|
||||||
|
|
||||||
|
// 사용자 정보
|
||||||
|
user?.id // UUID
|
||||||
|
user?.name // 이름
|
||||||
|
user?.roles // ['ADMIN', 'USER']
|
||||||
|
|
||||||
|
// 권한 확인 (탭 ID 기준)
|
||||||
|
hasPermission('prediction') // true/false
|
||||||
|
hasPermission('admin') // true/false
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API 클라이언트
|
||||||
|
```typescript
|
||||||
|
// frontend/src/services/api.ts
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
|
||||||
|
const response = await api.get('/your-endpoint')
|
||||||
|
const response = await api.post('/your-endpoint', data)
|
||||||
|
|
||||||
|
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 감사 로그 (Audit Log)
|
||||||
|
|
||||||
|
### 개요
|
||||||
|
사용자 행동을 추적하는 감사 로그 시스템. 현재 탭 이동 로그를 자동 기록하며, 향후 API 호출 로깅으로 확장 가능합니다.
|
||||||
|
|
||||||
|
### 자동 기록 (탭 이동)
|
||||||
|
`App.tsx`의 `useEffect`에서 `activeMainTab` 변경을 감지하여 `navigator.sendBeacon`으로 자동 전송합니다. 개별 탭 개발자는 별도 작업이 필요 없습니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/src/App.tsx (자동 적용, 수정 불필요)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
const blob = new Blob(
|
||||||
|
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||||
|
{ type: 'text/plain' }
|
||||||
|
)
|
||||||
|
navigator.sendBeacon('/api/audit/log', blob)
|
||||||
|
}, [activeMainTab, isAuthenticated])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수동 기록 (향후 확장)
|
||||||
|
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 프론트엔드에서 sendBeacon 사용
|
||||||
|
const blob = new Blob(
|
||||||
|
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
|
||||||
|
{ type: 'text/plain' }
|
||||||
|
)
|
||||||
|
navigator.sendBeacon('/api/audit/log', blob)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 용도 | 현재 사용 |
|
||||||
|
|------|------|------|-----------|
|
||||||
|
| LOG_SN | SERIAL PK | 로그 순번 | O |
|
||||||
|
| USER_ID | UUID | 사용자 ID | O |
|
||||||
|
| ACTION_CD | VARCHAR(30) | 액션 코드 | O (TAB_VIEW) |
|
||||||
|
| ACTION_DTL | VARCHAR(100) | 액션 상세 (탭ID 등) | O |
|
||||||
|
| HTTP_METHOD | VARCHAR(10) | GET/POST/PUT/DELETE | - (향후) |
|
||||||
|
| CRUD_TYPE | VARCHAR(10) | SELECT/INSERT/UPDATE/DELETE | - (향후) |
|
||||||
|
| REQ_URL | VARCHAR(500) | 요청 URL | - (향후) |
|
||||||
|
| REQ_DTM | TIMESTAMPTZ | 요청 시각 | O |
|
||||||
|
| RES_DTM | TIMESTAMPTZ | 응답 완료 시각 | - (향후) |
|
||||||
|
| RES_STATUS | SMALLINT | HTTP 상태 코드 | - (향후) |
|
||||||
|
| RES_SIZE | INTEGER | 응답 데이터 크기(bytes) | - (향후) |
|
||||||
|
| IP_ADDR | VARCHAR(45) | 클라이언트 IP | O |
|
||||||
|
| USER_AGENT | VARCHAR(500) | 브라우저 정보 | O |
|
||||||
|
| EXTRA | JSONB | 추가 메타데이터 | - (향후) |
|
||||||
|
|
||||||
|
### ACTION_CD 코드 체계
|
||||||
|
| 코드 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| TAB_VIEW | 상단 탭 이동 |
|
||||||
|
| API_CALL | API 호출 (향후) |
|
||||||
|
| LOGIN | 로그인 (향후) |
|
||||||
|
| LOGOUT | 로그아웃 (향후) |
|
||||||
|
| ADMIN_ACTION | 관리자 작업 (향후) |
|
||||||
|
|
||||||
|
### 관리자 조회 API
|
||||||
|
```typescript
|
||||||
|
// frontend/src/services/authApi.ts
|
||||||
|
import { fetchAuditLogs } from '../services/authApi'
|
||||||
|
|
||||||
|
const result = await fetchAuditLogs({
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
actionCd: 'TAB_VIEW',
|
||||||
|
from: '2026-02-28',
|
||||||
|
to: '2026-02-28',
|
||||||
|
})
|
||||||
|
// result: { items: AuditLogItem[], total: number, page: number, size: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 메뉴 시스템
|
||||||
|
|
||||||
|
### 개요
|
||||||
|
DB 기반 동적 메뉴 구성. 관리자가 메뉴 표시 여부/순서를 설정하면 모든 사용자에게 반영됩니다.
|
||||||
|
새 메뉴 탭 추가 시 `docs/MENU-TAB-GUIDE.md`를 참조하세요.
|
||||||
|
|
||||||
|
### 메뉴 상태 (menuStore)
|
||||||
|
```typescript
|
||||||
|
// frontend/src/store/menuStore.ts
|
||||||
|
import { useMenuStore } from '../store/menuStore'
|
||||||
|
|
||||||
|
const { menus, loadMenuConfig } = useMenuStore()
|
||||||
|
|
||||||
|
// menus: MenuConfigItem[] — 활성화되고 정렬된 메뉴 목록
|
||||||
|
// menus[0].id → 'prediction'
|
||||||
|
// menus[0].label → '유출유 확산예측'
|
||||||
|
// menus[0].enabled → true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 메뉴 설정 저장소
|
||||||
|
- DB: `AUTH_SETTING` 테이블의 `menu.config` 키 (JSON 배열)
|
||||||
|
- 백엔드: `backend/src/settings/settingsService.ts`의 `DEFAULT_MENU_CONFIG`
|
||||||
|
- API: `GET/PUT /api/menus`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 통신 패턴
|
||||||
|
|
||||||
|
### Axios 인스턴스 설정
|
||||||
|
```typescript
|
||||||
|
// frontend/src/services/api.ts
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api',
|
||||||
|
withCredentials: true, // JWT 쿠키 자동 포함
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 새 API 서비스 작성 패턴
|
||||||
|
```typescript
|
||||||
|
// frontend/src/services/newService.ts
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
export interface MyData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyData(): Promise<MyData[]> {
|
||||||
|
const response = await api.get<MyData[]>('/my-endpoint')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMyData(data: Omit<MyData, 'id'>): Promise<MyData> {
|
||||||
|
const response = await api.post<MyData>('/my-endpoint', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 에러 처리
|
||||||
|
- 401 응답: `api.ts` 인터셉터가 자동으로 로그아웃 처리
|
||||||
|
- 비즈니스 에러: `response.data.error` 메시지로 사용자에게 안내
|
||||||
|
- 백엔드에서 `AuthError` 사용 시 적절한 HTTP 상태 코드와 메시지 반환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 상태 관리
|
||||||
|
|
||||||
|
### Zustand (클라이언트 상태)
|
||||||
|
```typescript
|
||||||
|
// frontend/src/store/newStore.ts
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface MyState {
|
||||||
|
items: string[]
|
||||||
|
addItem: (item: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMyStore = create<MyState>((set) => ({
|
||||||
|
items: [],
|
||||||
|
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### TanStack Query (서버 상태) — 권장
|
||||||
|
```typescript
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { fetchMyData, createMyData } from '../services/newService'
|
||||||
|
|
||||||
|
// 조회
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['myData'],
|
||||||
|
queryFn: fetchMyData,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 생성/수정
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createMyData,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['myData'] }),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 백엔드 모듈 추가 절차
|
||||||
|
|
||||||
|
새 백엔드 모듈을 추가할 때:
|
||||||
|
|
||||||
|
1. `backend/src/[모듈명]/` 디렉토리 생성
|
||||||
|
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
|
||||||
|
3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리)
|
||||||
|
4. `backend/src/server.ts`에 라우터 등록:
|
||||||
|
```typescript
|
||||||
|
import newRouter from './[모듈명]/[모듈명]Router.js'
|
||||||
|
app.use('/api/[경로]', newRouter)
|
||||||
|
```
|
||||||
|
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
|
||||||
|
|
||||||
|
### DB 접근
|
||||||
|
```typescript
|
||||||
|
// PostgreSQL (인증 DB)
|
||||||
|
import { authPool } from '../db/authDb.js'
|
||||||
|
const result = await authPool.query('SELECT * FROM TABLE WHERE id = $1', [id])
|
||||||
|
|
||||||
|
// SQLite (레이어 DB)
|
||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare('SELECT * FROM table').all()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── services/api.ts Axios 인스턴스 + 인터셉터
|
||||||
|
├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
||||||
|
├── store/authStore.ts 인증 상태 (Zustand)
|
||||||
|
├── store/menuStore.ts 메뉴 상태 (Zustand)
|
||||||
|
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
||||||
|
|
||||||
|
backend/src/
|
||||||
|
├── auth/ 인증 (JWT, OAuth, 미들웨어)
|
||||||
|
├── users/ 사용자 관리
|
||||||
|
├── roles/ 역할/권한 관리
|
||||||
|
├── settings/ 시스템 설정
|
||||||
|
├── menus/ 메뉴 설정
|
||||||
|
├── audit/ 감사 로그
|
||||||
|
├── db/ DB 연결 (authDb, database)
|
||||||
|
├── middleware/ 보안 미들웨어
|
||||||
|
└── server.ts Express 진입점 + 라우터 등록
|
||||||
|
```
|
||||||
@ -40,6 +40,16 @@ function App() {
|
|||||||
registerMainTabSwitcher(setActiveMainTab)
|
registerMainTabSwitcher(setActiveMainTab)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 감사 로그: 탭 이동 기록
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
const blob = new Blob(
|
||||||
|
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||||
|
{ type: 'text/plain' }
|
||||||
|
)
|
||||||
|
navigator.sendBeacon('/api/audit/log', blob)
|
||||||
|
}, [activeMainTab, isAuthenticated])
|
||||||
|
|
||||||
// 세션 확인 중 스플래시
|
// 세션 확인 중 스플래시
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -219,3 +219,42 @@ export async function updateMenuConfigApi(menus: MenuConfigItem[]): Promise<Menu
|
|||||||
const response = await api.put<MenuConfigItem[]>('/menus', { menus })
|
const response = await api.put<MenuConfigItem[]>('/menus', { menus })
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 감사 로그 API (ADMIN 전용)
|
||||||
|
export interface AuditLogItem {
|
||||||
|
logSn: number
|
||||||
|
userId: string
|
||||||
|
userName: string | null
|
||||||
|
userAccount: string | null
|
||||||
|
actionCd: string
|
||||||
|
actionDtl: string | null
|
||||||
|
httpMethod: string | null
|
||||||
|
crudType: string | null
|
||||||
|
reqUrl: string | null
|
||||||
|
reqDtm: string
|
||||||
|
resDtm: string | null
|
||||||
|
resStatus: number | null
|
||||||
|
resSize: number | null
|
||||||
|
ipAddr: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
extra: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogListResult {
|
||||||
|
items: AuditLogItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuditLogs(params?: {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
userId?: string
|
||||||
|
actionCd?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}): Promise<AuditLogListResult> {
|
||||||
|
const response = await api.get<AuditLogListResult>('/audit/logs', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user