generated from gc/template-java-maven
Merge pull request 'feat(phase4): API Gateway 프록시 + 요청 로깅' (#16) from feature/ISSUE-9-phase4-gateway-logging into develop
This commit is contained in:
커밋
b5a29bfdfd
@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
||||
- API Key 신청 필드 (사용기간, 서비스IP, 서비스용도, 하루 예상 요청량) (#8)
|
||||
- API Key Permission 관리 API (#8)
|
||||
- API Key 관리 프론트엔드 (신청/검토/키관리/권한편집) (#8)
|
||||
- API Gateway 프록시 (ANY /gateway/{serviceCode}/**) (#9)
|
||||
- API Key 인증 필터 (prefix 매칭 + AES 복호화 + 권한 확인) (#9)
|
||||
- 요청/응답 비동기 로깅 (@Async) (#9)
|
||||
- 요청 로그 검색 API (JPA Specification 동적 쿼리) (#9)
|
||||
- 요청 로그 검색/상세 프론트엔드 페이지 (#9)
|
||||
|
||||
## [2026-04-07]
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import RequestLogsPage from './pages/monitoring/RequestLogsPage';
|
||||
import RequestLogDetailPage from './pages/monitoring/RequestLogDetailPage';
|
||||
import MyKeysPage from './pages/apikeys/MyKeysPage';
|
||||
import KeyRequestPage from './pages/apikeys/KeyRequestPage';
|
||||
import KeyAdminPage from './pages/apikeys/KeyAdminPage';
|
||||
@ -30,6 +31,7 @@ const App = () => {
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
|
||||
<Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
|
||||
<Route path="/apikeys/my-keys" element={<MyKeysPage />} />
|
||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||
<Route path="/apikeys/admin" element={<KeyAdminPage />} />
|
||||
|
||||
210
frontend/src/pages/monitoring/RequestLogDetailPage.tsx
Normal file
210
frontend/src/pages/monitoring/RequestLogDetailPage.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { RequestLog } from '../../types/monitoring';
|
||||
import { getLogDetail } from '../../services/monitoringService';
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
GET: 'bg-green-100 text-green-800',
|
||||
POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-orange-100 text-orange-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
SUCCESS: 'bg-green-100 text-green-800',
|
||||
DENIED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-orange-100 text-orange-800',
|
||||
INVALID_KEY: 'bg-red-100 text-red-800',
|
||||
FAILED: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const formatJson = (str: string | null): string | null => {
|
||||
if (!str) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(str), null, 2);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const RequestLogDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [log, setLog] = useState<RequestLog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchDetail = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getLogDetail(Number(id));
|
||||
if (res.success && res.data) {
|
||||
setLog(res.data);
|
||||
} else {
|
||||
setError(res.message || '로그 상세 조회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('로그 상세 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium mb-4"
|
||||
>
|
||||
← 목록으로
|
||||
</button>
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!log) return null;
|
||||
|
||||
const formattedHeaders = formatJson(log.requestHeaders);
|
||||
const formattedParams = formatJson(log.requestParams);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium mb-6"
|
||||
>
|
||||
← 목록으로
|
||||
</button>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">기본 정보</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">요청 시간</span>
|
||||
<span className="text-sm text-gray-900">{formatDateTime(log.requestedAt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">요청</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold mr-2 ${
|
||||
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{log.requestMethod}
|
||||
</span>
|
||||
{log.requestUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">응답 코드</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">응답시간(ms)</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{log.responseTime != null ? log.responseTime : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">응답크기(bytes)</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{log.responseSize != null ? log.responseSize : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">상태</span>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">서비스</span>
|
||||
<span className="text-sm text-gray-900">{log.serviceName || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">API Key</span>
|
||||
<span className="text-sm text-gray-900 font-mono">
|
||||
{log.apiKeyPrefix || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">사용자</span>
|
||||
<span className="text-sm text-gray-900">{log.userName || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">IP</span>
|
||||
<span className="text-sm text-gray-900 font-mono">{log.requestIp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요청 정보 */}
|
||||
{(formattedHeaders || formattedParams) && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">요청 정보</h2>
|
||||
{formattedHeaders && (
|
||||
<div className="mb-4">
|
||||
<span className="block text-sm font-medium text-gray-500 mb-1">Request Headers</span>
|
||||
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto">
|
||||
{formattedHeaders}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{formattedParams && (
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 mb-1">Request Params</span>
|
||||
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto">
|
||||
{formattedParams}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 정보 */}
|
||||
{log.errorMessage && (
|
||||
<div className="bg-red-50 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-red-900 mb-2">에러 정보</h2>
|
||||
<p className="text-sm text-red-800">{log.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestLogDetailPage;
|
||||
@ -1,8 +1,360 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { RequestLog, PageResponse } from '../../types/monitoring';
|
||||
import type { ServiceInfo } from '../../types/service';
|
||||
import { searchLogs } from '../../services/monitoringService';
|
||||
import { getServices } from '../../services/serviceService';
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
GET: 'bg-green-100 text-green-800',
|
||||
POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-orange-100 text-orange-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
SUCCESS: 'bg-green-100 text-green-800',
|
||||
DENIED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-orange-100 text-orange-800',
|
||||
INVALID_KEY: 'bg-red-100 text-red-800',
|
||||
FAILED: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const REQUEST_STATUSES = ['SUCCESS', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'FAILED'];
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
const getTodayString = (): string => {
|
||||
const d = new Date();
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const RequestLogsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [startDate, setStartDate] = useState(getTodayString());
|
||||
const [endDate, setEndDate] = useState(getTodayString());
|
||||
const [serviceId, setServiceId] = useState('');
|
||||
const [requestStatus, setRequestStatus] = useState('');
|
||||
const [requestMethod, setRequestMethod] = useState('');
|
||||
const [requestIp, setRequestIp] = useState('');
|
||||
|
||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res = await getServices();
|
||||
if (res.success && res.data) {
|
||||
setServices(res.data);
|
||||
}
|
||||
} catch {
|
||||
// 서비스 목록 로딩 실패는 무시
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (page: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCurrentPage(page);
|
||||
|
||||
try {
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
serviceId: serviceId ? Number(serviceId) : undefined,
|
||||
requestStatus: requestStatus || undefined,
|
||||
requestMethod: requestMethod || undefined,
|
||||
requestIp: requestIp || undefined,
|
||||
page,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
const res = await searchLogs(params);
|
||||
if (res.success && res.data) {
|
||||
setResult(res.data);
|
||||
} else {
|
||||
setError(res.message || '로그 조회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('로그 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStartDate(getTodayString());
|
||||
setEndDate(getTodayString());
|
||||
setServiceId('');
|
||||
setRequestStatus('');
|
||||
setRequestMethod('');
|
||||
setRequestIp('');
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
handleSearch(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleResetAndSearch = () => {
|
||||
handleReset();
|
||||
// 초기화 후 기본값으로 검색 (setState는 비동기이므로 직접 호출)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCurrentPage(0);
|
||||
const today = getTodayString();
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
startDate: today,
|
||||
endDate: today,
|
||||
page: 0,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
searchLogs(params)
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setResult(res.data);
|
||||
} else {
|
||||
setError(res.message || '로그 조회에 실패했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('로그 조회에 실패했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowClick = (logId: number) => {
|
||||
navigate(`/monitoring/request-logs/${logId}`);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentPage > 0) {
|
||||
handleSearch(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (result && currentPage < result.totalPages - 1) {
|
||||
handleSearch(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Request Logs</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Request Logs</h1>
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">기간</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<span className="text-gray-500">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">서비스</label>
|
||||
<select
|
||||
value={serviceId}
|
||||
onChange={(e) => setServiceId(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.serviceId} value={s.serviceId}>
|
||||
{s.serviceName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select
|
||||
value={requestStatus}
|
||||
onChange={(e) => setRequestStatus(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">HTTP Method</label>
|
||||
<select
|
||||
value={requestMethod}
|
||||
onChange={(e) => setRequestMethod(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={requestIp}
|
||||
onChange={(e) => setRequestIp(e.target.value)}
|
||||
placeholder="IP 주소"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
onClick={() => handleSearch(0)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetAndSearch}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Results Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow mb-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">시간</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">서비스</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Method</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Status Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">응답시간(ms)</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">상태</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{result && result.content.length > 0 ? (
|
||||
result.content.map((log) => (
|
||||
<tr
|
||||
key={log.logId}
|
||||
onClick={() => handleRowClick(log.logId)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{formatDateTime(log.requestedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{log.serviceName || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{log.requestMethod}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 truncate max-w-[250px]" title={log.requestUrl}>
|
||||
{log.requestUrl}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{log.responseTime != null ? log.responseTime : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{log.requestIp}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
검색 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{result && result.totalElements > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
총 {result.totalElements}건 / {result.page + 1} / {result.totalPages} 페이지
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={currentPage === 0}
|
||||
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!result || currentPage >= result.totalPages - 1}
|
||||
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
12
frontend/src/services/monitoringService.ts
Normal file
12
frontend/src/services/monitoringService.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { get } from './apiClient';
|
||||
import type { RequestLog, PageResponse } from '../types/monitoring';
|
||||
|
||||
export const searchLogs = (params: Record<string, string | number | undefined>) => {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
return get<PageResponse<RequestLog>>(`/monitoring/logs${query ? '?' + query : ''}`);
|
||||
};
|
||||
|
||||
export const getLogDetail = (id: number) => get<RequestLog>(`/monitoring/logs/${id}`);
|
||||
37
frontend/src/types/monitoring.ts
Normal file
37
frontend/src/types/monitoring.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export interface RequestLog {
|
||||
logId: number;
|
||||
requestUrl: string;
|
||||
requestMethod: string;
|
||||
requestStatus: string;
|
||||
requestIp: string;
|
||||
responseStatus: number | null;
|
||||
responseTime: number | null;
|
||||
responseSize: number | null;
|
||||
errorMessage: string | null;
|
||||
requestedAt: string;
|
||||
serviceId: number | null;
|
||||
serviceName: string | null;
|
||||
apiKeyPrefix: string | null;
|
||||
userName: string | null;
|
||||
requestHeaders: string | null;
|
||||
requestParams: string | null;
|
||||
}
|
||||
|
||||
export interface RequestLogSearch {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
serviceId?: number;
|
||||
requestStatus?: string;
|
||||
requestMethod?: string;
|
||||
requestIp?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
@ -11,4 +11,6 @@ public interface SnpApiKeyRepository extends JpaRepository<SnpApiKey, Long> {
|
||||
Optional<SnpApiKey> findByApiKey(String apiKey);
|
||||
|
||||
List<SnpApiKey> findByUserUserId(Long userId);
|
||||
|
||||
List<SnpApiKey> findByApiKeyPrefix(String apiKeyPrefix);
|
||||
}
|
||||
|
||||
@ -6,9 +6,12 @@ import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SnpApiPermissionRepository extends JpaRepository<SnpApiPermission, Long> {
|
||||
|
||||
Optional<SnpApiPermission> findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(Long apiKeyId, Long apiId);
|
||||
|
||||
List<SnpApiPermission> findByApiKeyApiKeyId(Long apiKeyId);
|
||||
|
||||
@Modifying
|
||||
|
||||
@ -25,6 +25,12 @@ public enum ErrorCode {
|
||||
API_KEY_REQUEST_NOT_FOUND(404, "KEY003", "API Key 신청을 찾을 수 없습니다"),
|
||||
API_KEY_REQUEST_ALREADY_PROCESSED(409, "KEY004", "이미 처리된 신청입니다"),
|
||||
ENCRYPTION_ERROR(500, "KEY005", "암호화 처리 중 오류가 발생했습니다"),
|
||||
GATEWAY_API_KEY_MISSING(401, "GW001", "API Key가 필요합니다"),
|
||||
GATEWAY_API_KEY_INVALID(401, "GW002", "유효하지 않은 API Key입니다"),
|
||||
GATEWAY_API_KEY_EXPIRED(403, "GW003", "만료된 API Key입니다"),
|
||||
GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"),
|
||||
GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
|
||||
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
|
||||
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
|
||||
|
||||
private final int status;
|
||||
|
||||
24
src/main/java/com/gcsc/connection/config/AsyncConfig.java
Normal file
24
src/main/java/com/gcsc/connection/config/AsyncConfig.java
Normal file
@ -0,0 +1,24 @@
|
||||
package com.gcsc.connection.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
@Bean("taskExecutor")
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(5);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("async-log-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
|
||||
.requestMatchers("/gateway/**").permitAll()
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll())
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
package com.gcsc.connection.gateway.controller;
|
||||
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.gateway.service.GatewayService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* API Gateway 프록시 엔드포인트
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/gateway")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class GatewayController {
|
||||
|
||||
private final GatewayService gatewayService;
|
||||
|
||||
/**
|
||||
* 모든 HTTP 메서드에 대한 프록시 요청 처리
|
||||
*/
|
||||
@RequestMapping(
|
||||
value = "/{serviceCode}/**",
|
||||
method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT,
|
||||
RequestMethod.DELETE, RequestMethod.PATCH}
|
||||
)
|
||||
public ResponseEntity<byte[]> proxy(@PathVariable String serviceCode,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String remainingPath = extractRemainingPath(serviceCode, request);
|
||||
return gatewayService.proxyRequest(serviceCode, remainingPath, request);
|
||||
} catch (BusinessException e) {
|
||||
return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /gateway/{serviceCode}/ 이후의 경로 추출
|
||||
*/
|
||||
private String extractRemainingPath(String serviceCode, HttpServletRequest request) {
|
||||
String fullPath = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
String prefix = contextPath + "/gateway/" + serviceCode;
|
||||
String remainingPath = fullPath.substring(prefix.length());
|
||||
|
||||
if (remainingPath.isEmpty()) {
|
||||
remainingPath = "/";
|
||||
}
|
||||
|
||||
return remainingPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 소비자용 JSON 에러 응답 생성
|
||||
*/
|
||||
private ResponseEntity<byte[]> buildErrorResponse(int status, String message) {
|
||||
String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}";
|
||||
return ResponseEntity.status(status)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(json.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열 이스케이프 처리
|
||||
*/
|
||||
private String escapeJson(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,309 @@
|
||||
package com.gcsc.connection.gateway.service;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.ApiKeyStatus;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiPermissionRepository;
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.common.util.AesEncryptor;
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
import com.gcsc.connection.monitoring.service.RequestLogService;
|
||||
import com.gcsc.connection.service.entity.SnpService;
|
||||
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
||||
import com.gcsc.connection.service.repository.SnpServiceRepository;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class GatewayService {
|
||||
|
||||
private static final int API_KEY_PREFIX_LENGTH = 8;
|
||||
private static final Set<String> EXCLUDED_HEADERS = Set.of(
|
||||
"host", "x-api-key", "connection", "content-length"
|
||||
);
|
||||
|
||||
private final SnpApiKeyRepository snpApiKeyRepository;
|
||||
private final SnpApiPermissionRepository snpApiPermissionRepository;
|
||||
private final SnpServiceRepository snpServiceRepository;
|
||||
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||
private final AesEncryptor aesEncryptor;
|
||||
private final WebClient webClient;
|
||||
private final RequestLogService requestLogService;
|
||||
|
||||
/**
|
||||
* API Gateway 프록시 요청 처리
|
||||
*/
|
||||
public ResponseEntity<byte[]> proxyRequest(String serviceCode, String remainingPath,
|
||||
HttpServletRequest request) {
|
||||
LocalDateTime requestedAt = LocalDateTime.now();
|
||||
long startTime = System.currentTimeMillis();
|
||||
SnpApiKey apiKey = null;
|
||||
SnpService service = null;
|
||||
String gatewayPath = "/gateway/" + serviceCode + remainingPath
|
||||
+ (request.getQueryString() != null ? "?" + request.getQueryString() : "");
|
||||
String targetUrl = null;
|
||||
|
||||
try {
|
||||
// 1. API Key 추출
|
||||
String rawKey = request.getHeader("X-API-KEY");
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING);
|
||||
}
|
||||
|
||||
// 2. API Key 검증 (prefix 매칭 후 복호화 비교)
|
||||
apiKey = findApiKeyByRawKey(rawKey);
|
||||
|
||||
// 3. Key 상태/만료 검증
|
||||
validateApiKey(apiKey);
|
||||
|
||||
// 4. 서비스 조회
|
||||
service = snpServiceRepository.findByServiceCode(serviceCode)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||
|
||||
if (!Boolean.TRUE.equals(service.getIsActive())) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_SERVICE_INACTIVE);
|
||||
}
|
||||
|
||||
// 5. 대상 URL 조합 (실패 로그에도 사용)
|
||||
targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request);
|
||||
|
||||
// 6. ServiceApi 조회 (경로 + 메서드 매칭)
|
||||
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
|
||||
SnpServiceApi serviceApi = snpServiceApiRepository
|
||||
.findByServiceServiceIdAndApiPathAndApiMethod(
|
||||
service.getServiceId(), apiPath, request.getMethod())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED));
|
||||
|
||||
// 6. 권한 확인
|
||||
snpApiPermissionRepository
|
||||
.findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(apiKey.getApiKeyId(), serviceApi.getApiId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED));
|
||||
|
||||
// 7. 프록시 요청 전송
|
||||
ResponseEntity<byte[]> response = forwardRequest(targetUrl, request);
|
||||
|
||||
// 9. 마지막 사용 시간 갱신
|
||||
apiKey.updateLastUsedAt();
|
||||
snpApiKeyRepository.save(apiKey);
|
||||
|
||||
// 10. 성공 로그 저장
|
||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "SUCCESS",
|
||||
response.getStatusCode().value(), responseTime,
|
||||
response.getBody() != null ? (long) response.getBody().length : 0L,
|
||||
null, requestedAt);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (BusinessException e) {
|
||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "FAIL",
|
||||
e.getErrorCode().getStatus(), responseTime, 0L,
|
||||
e.getErrorCode().getMessage(), requestedAt);
|
||||
throw e;
|
||||
|
||||
} catch (Exception e) {
|
||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||
log.error("Gateway 프록시 요청 실패: {}", e.getMessage(), e);
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "ERROR",
|
||||
502, responseTime, 0L, e.getMessage(), requestedAt);
|
||||
throw new BusinessException(ErrorCode.GATEWAY_PROXY_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* prefix 매칭 후 복호화하여 API Key 찾기
|
||||
*/
|
||||
private SnpApiKey findApiKeyByRawKey(String rawKey) {
|
||||
if (rawKey.length() < API_KEY_PREFIX_LENGTH) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);
|
||||
}
|
||||
|
||||
String prefix = rawKey.substring(0, API_KEY_PREFIX_LENGTH);
|
||||
List<SnpApiKey> candidates = snpApiKeyRepository.findByApiKeyPrefix(prefix);
|
||||
|
||||
for (SnpApiKey candidate : candidates) {
|
||||
try {
|
||||
String decryptedKey = aesEncryptor.decrypt(candidate.getApiKey());
|
||||
if (rawKey.equals(decryptedKey)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("API Key 복호화 비교 실패 (apiKeyId={})", candidate.getApiKeyId());
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 상태 및 만료 검증
|
||||
*/
|
||||
private void validateApiKey(SnpApiKey apiKey) {
|
||||
if (apiKey.getStatus() != ApiKeyStatus.ACTIVE) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);
|
||||
}
|
||||
|
||||
if (apiKey.getExpiresAt() != null && apiKey.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 URL 구성
|
||||
*/
|
||||
private String buildTargetUrl(String serviceUrl, String remainingPath, HttpServletRequest request) {
|
||||
StringBuilder url = new StringBuilder(serviceUrl);
|
||||
if (!remainingPath.isEmpty() && !"/".equals(remainingPath)) {
|
||||
if (!serviceUrl.endsWith("/") && !remainingPath.startsWith("/")) {
|
||||
url.append("/");
|
||||
}
|
||||
url.append(remainingPath);
|
||||
}
|
||||
|
||||
String queryString = request.getQueryString();
|
||||
if (queryString != null && !queryString.isEmpty()) {
|
||||
url.append("?").append(queryString);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* WebClient를 통한 프록시 요청 전송
|
||||
*/
|
||||
private ResponseEntity<byte[]> forwardRequest(String targetUrl, HttpServletRequest request) {
|
||||
HttpMethod method = HttpMethod.valueOf(request.getMethod());
|
||||
|
||||
WebClient.RequestBodySpec requestSpec = webClient.method(method)
|
||||
.uri(targetUrl)
|
||||
.headers(headers -> copyHeaders(request, headers));
|
||||
|
||||
WebClient.RequestHeadersSpec<?> headersSpec;
|
||||
if (hasRequestBody(method)) {
|
||||
headersSpec = requestSpec.bodyValue(readRequestBody(request));
|
||||
} else {
|
||||
headersSpec = requestSpec;
|
||||
}
|
||||
|
||||
try {
|
||||
return headersSpec.retrieve()
|
||||
.toEntity(byte[].class)
|
||||
.block();
|
||||
} catch (WebClientResponseException e) {
|
||||
// 대상 서버에서 에러 응답이 와도 그대로 전달
|
||||
return ResponseEntity
|
||||
.status(e.getStatusCode())
|
||||
.headers(e.getHeaders())
|
||||
.body(e.getResponseBodyAsByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더 복사 (제외 대상 헤더 필터링)
|
||||
*/
|
||||
private void copyHeaders(HttpServletRequest request, HttpHeaders headers) {
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
if (!EXCLUDED_HEADERS.contains(headerName.toLowerCase())) {
|
||||
headers.set(headerName, request.getHeader(headerName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST/PUT/PATCH 메서드 여부 확인
|
||||
*/
|
||||
private boolean hasRequestBody(HttpMethod method) {
|
||||
return HttpMethod.POST.equals(method)
|
||||
|| HttpMethod.PUT.equals(method)
|
||||
|| HttpMethod.PATCH.equals(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 본문 읽기
|
||||
*/
|
||||
private byte[] readRequestBody(HttpServletRequest request) {
|
||||
try {
|
||||
return request.getInputStream().readAllBytes();
|
||||
} catch (Exception e) {
|
||||
log.warn("요청 본문 읽기 실패", e);
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비동기 요청 로그 저장
|
||||
*/
|
||||
private void saveLog(HttpServletRequest request, SnpService service, SnpApiKey apiKey,
|
||||
String targetUrl, String gatewayPath, String status, Integer responseStatus,
|
||||
Integer responseTime, Long responseSize, String errorMessage,
|
||||
LocalDateTime requestedAt) {
|
||||
try {
|
||||
SnpApiRequestLog logEntry = SnpApiRequestLog.builder()
|
||||
.requestUrl(gatewayPath)
|
||||
.requestMethod(request.getMethod())
|
||||
.requestStatus(status)
|
||||
.requestIp(getClientIp(request))
|
||||
.requestHeaders(extractHeaders(request))
|
||||
.requestParams(request.getQueryString())
|
||||
.service(service)
|
||||
.user(apiKey != null ? apiKey.getUser() : null)
|
||||
.apiKey(apiKey)
|
||||
.tenant(apiKey != null && apiKey.getUser() != null ? apiKey.getUser().getTenant() : null)
|
||||
.responseStatus(responseStatus)
|
||||
.responseTime(responseTime)
|
||||
.responseSize(responseSize)
|
||||
.errorMessage(errorMessage)
|
||||
.requestedAt(requestedAt)
|
||||
.build();
|
||||
|
||||
requestLogService.saveLogAsync(logEntry);
|
||||
} catch (Exception e) {
|
||||
log.error("로그 엔트리 생성 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 IP 추출
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더를 문자열로 변환
|
||||
*/
|
||||
private String extractHeaders(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String name = headerNames.nextElement();
|
||||
if (!"x-api-key".equalsIgnoreCase(name)) {
|
||||
sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.gcsc.connection.monitoring.controller;
|
||||
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import com.gcsc.connection.common.dto.PageResponse;
|
||||
import com.gcsc.connection.monitoring.dto.RequestLogResponse;
|
||||
import com.gcsc.connection.monitoring.dto.RequestLogSearchDto;
|
||||
import com.gcsc.connection.monitoring.service.RequestLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 요청 로그 조회 API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/monitoring")
|
||||
@RequiredArgsConstructor
|
||||
public class RequestLogController {
|
||||
|
||||
private final RequestLogService requestLogService;
|
||||
|
||||
/**
|
||||
* 요청 로그 검색
|
||||
*/
|
||||
@GetMapping("/logs")
|
||||
public ResponseEntity<ApiResponse<PageResponse<RequestLogResponse>>> searchLogs(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate,
|
||||
@RequestParam(required = false) Long serviceId,
|
||||
@RequestParam(required = false) String requestStatus,
|
||||
@RequestParam(required = false) String requestMethod,
|
||||
@RequestParam(required = false) String requestIp,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
LocalDateTime startDateTime = startDate != null ? startDate.atStartOfDay() : null;
|
||||
LocalDateTime endDateTime = endDate != null ? endDate.plusDays(1).atStartOfDay() : null;
|
||||
RequestLogSearchDto searchDto = new RequestLogSearchDto(
|
||||
startDateTime, endDateTime, serviceId, requestStatus, requestMethod, requestIp, page, size);
|
||||
Page<RequestLogResponse> result = requestLogService.searchLogs(searchDto);
|
||||
return ResponseEntity.ok(ApiResponse.ok(PageResponse.from(result)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 로그 상세 조회
|
||||
*/
|
||||
@GetMapping("/logs/{id}")
|
||||
public ResponseEntity<ApiResponse<RequestLogResponse>> getLogDetail(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(ApiResponse.ok(requestLogService.getLogDetail(id)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record RequestLogResponse(
|
||||
Long logId,
|
||||
String requestUrl,
|
||||
String requestMethod,
|
||||
String requestStatus,
|
||||
String requestIp,
|
||||
Integer responseStatus,
|
||||
Integer responseTime,
|
||||
Long responseSize,
|
||||
String errorMessage,
|
||||
LocalDateTime requestedAt,
|
||||
Long serviceId,
|
||||
String serviceName,
|
||||
String apiKeyPrefix,
|
||||
String userName,
|
||||
String requestHeaders,
|
||||
String requestParams
|
||||
) {
|
||||
|
||||
/**
|
||||
* 목록 조회용 변환 (헤더/파라미터 제외)
|
||||
*/
|
||||
public static RequestLogResponse from(SnpApiRequestLog log) {
|
||||
return new RequestLogResponse(
|
||||
log.getLogId(),
|
||||
log.getRequestUrl(),
|
||||
log.getRequestMethod(),
|
||||
log.getRequestStatus(),
|
||||
log.getRequestIp(),
|
||||
log.getResponseStatus(),
|
||||
log.getResponseTime(),
|
||||
log.getResponseSize(),
|
||||
log.getErrorMessage(),
|
||||
log.getRequestedAt(),
|
||||
log.getService() != null ? log.getService().getServiceId() : null,
|
||||
log.getService() != null ? log.getService().getServiceName() : null,
|
||||
log.getApiKey() != null ? log.getApiKey().getApiKeyPrefix() : null,
|
||||
log.getUser() != null ? log.getUser().getUserName() : null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 조회용 변환 (모든 필드 포함)
|
||||
*/
|
||||
public static RequestLogResponse fromDetail(SnpApiRequestLog log) {
|
||||
return new RequestLogResponse(
|
||||
log.getLogId(),
|
||||
log.getRequestUrl(),
|
||||
log.getRequestMethod(),
|
||||
log.getRequestStatus(),
|
||||
log.getRequestIp(),
|
||||
log.getResponseStatus(),
|
||||
log.getResponseTime(),
|
||||
log.getResponseSize(),
|
||||
log.getErrorMessage(),
|
||||
log.getRequestedAt(),
|
||||
log.getService() != null ? log.getService().getServiceId() : null,
|
||||
log.getService() != null ? log.getService().getServiceName() : null,
|
||||
log.getApiKey() != null ? log.getApiKey().getApiKeyPrefix() : null,
|
||||
log.getUser() != null ? log.getUser().getUserName() : null,
|
||||
log.getRequestHeaders(),
|
||||
log.getRequestParams()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record RequestLogSearchDto(
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
Long serviceId,
|
||||
String requestStatus,
|
||||
String requestMethod,
|
||||
String requestIp,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
|
||||
public RequestLogSearchDto {
|
||||
if (page < 0) page = 0;
|
||||
if (size <= 0) size = 20;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@ package com.gcsc.connection.monitoring.repository;
|
||||
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long> {
|
||||
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
|
||||
JpaSpecificationExecutor<SnpApiRequestLog> {
|
||||
}
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
package com.gcsc.connection.monitoring.service;
|
||||
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.monitoring.dto.RequestLogResponse;
|
||||
import com.gcsc.connection.monitoring.dto.RequestLogSearchDto;
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
|
||||
import com.gcsc.connection.monitoring.spec.RequestLogSpecification;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class RequestLogService {
|
||||
|
||||
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
|
||||
|
||||
/**
|
||||
* 비동기 요청 로그 저장
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional
|
||||
public void saveLogAsync(SnpApiRequestLog logEntry) {
|
||||
try {
|
||||
snpApiRequestLogRepository.save(logEntry);
|
||||
} catch (Exception e) {
|
||||
log.error("요청 로그 저장 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 로그 검색
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<RequestLogResponse> searchLogs(RequestLogSearchDto searchDto) {
|
||||
Specification<SnpApiRequestLog> spec = RequestLogSpecification.withFilters(searchDto);
|
||||
Pageable pageable = PageRequest.of(searchDto.page(), searchDto.size());
|
||||
return snpApiRequestLogRepository.findAll(spec, pageable).map(RequestLogResponse::from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 로그 상세 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public RequestLogResponse getLogDetail(Long logId) {
|
||||
return snpApiRequestLogRepository.findById(logId)
|
||||
.map(RequestLogResponse::fromDetail)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.gcsc.connection.monitoring.spec;
|
||||
|
||||
import com.gcsc.connection.monitoring.dto.RequestLogSearchDto;
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class RequestLogSpecification {
|
||||
|
||||
private RequestLogSpecification() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 조건 기반 Specification 생성
|
||||
*/
|
||||
public static Specification<SnpApiRequestLog> withFilters(RequestLogSearchDto dto) {
|
||||
return (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
if (dto.startDate() != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("requestedAt"), dto.startDate()));
|
||||
}
|
||||
if (dto.endDate() != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("requestedAt"), dto.endDate()));
|
||||
}
|
||||
if (dto.serviceId() != null) {
|
||||
predicates.add(cb.equal(root.get("service").get("serviceId"), dto.serviceId()));
|
||||
}
|
||||
if (dto.requestStatus() != null && !dto.requestStatus().isBlank()) {
|
||||
predicates.add(cb.equal(root.get("requestStatus"), dto.requestStatus()));
|
||||
}
|
||||
if (dto.requestMethod() != null && !dto.requestMethod().isBlank()) {
|
||||
predicates.add(cb.equal(root.get("requestMethod"), dto.requestMethod()));
|
||||
}
|
||||
if (dto.requestIp() != null && !dto.requestIp().isBlank()) {
|
||||
predicates.add(cb.like(root.get("requestIp"), dto.requestIp() + "%"));
|
||||
}
|
||||
|
||||
query.orderBy(cb.desc(root.get("requestedAt")));
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,14 @@ import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SnpServiceApiRepository extends JpaRepository<SnpServiceApi, Long> {
|
||||
|
||||
List<SnpServiceApi> findByService(SnpService service);
|
||||
|
||||
List<SnpServiceApi> findByServiceServiceId(Long serviceId);
|
||||
|
||||
Optional<SnpServiceApi> findByServiceServiceIdAndApiPathAndApiMethod(
|
||||
Long serviceId, String apiPath, String apiMethod);
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user