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 신청 필드 (사용기간, 서비스IP, 서비스용도, 하루 예상 요청량) (#8)
|
||||||
- API Key Permission 관리 API (#8)
|
- API Key Permission 관리 API (#8)
|
||||||
- API Key 관리 프론트엔드 (신청/검토/키관리/권한편집) (#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]
|
## [2026-04-07]
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import ProtectedRoute from './components/ProtectedRoute';
|
|||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import RequestLogsPage from './pages/monitoring/RequestLogsPage';
|
import RequestLogsPage from './pages/monitoring/RequestLogsPage';
|
||||||
|
import RequestLogDetailPage from './pages/monitoring/RequestLogDetailPage';
|
||||||
import MyKeysPage from './pages/apikeys/MyKeysPage';
|
import MyKeysPage from './pages/apikeys/MyKeysPage';
|
||||||
import KeyRequestPage from './pages/apikeys/KeyRequestPage';
|
import KeyRequestPage from './pages/apikeys/KeyRequestPage';
|
||||||
import KeyAdminPage from './pages/apikeys/KeyAdminPage';
|
import KeyAdminPage from './pages/apikeys/KeyAdminPage';
|
||||||
@ -30,6 +31,7 @@ const App = () => {
|
|||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
|
<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/my-keys" element={<MyKeysPage />} />
|
||||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||||
<Route path="/apikeys/admin" element={<KeyAdminPage />} />
|
<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 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Request Logs</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Request Logs</h1>
|
||||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
|
||||||
|
{/* 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>
|
</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);
|
Optional<SnpApiKey> findByApiKey(String apiKey);
|
||||||
|
|
||||||
List<SnpApiKey> findByUserUserId(Long userId);
|
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 org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface SnpApiPermissionRepository extends JpaRepository<SnpApiPermission, Long> {
|
public interface SnpApiPermissionRepository extends JpaRepository<SnpApiPermission, Long> {
|
||||||
|
|
||||||
|
Optional<SnpApiPermission> findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(Long apiKeyId, Long apiId);
|
||||||
|
|
||||||
List<SnpApiPermission> findByApiKeyApiKeyId(Long apiKeyId);
|
List<SnpApiPermission> findByApiKeyApiKeyId(Long apiKeyId);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
|
|||||||
@ -25,6 +25,12 @@ public enum ErrorCode {
|
|||||||
API_KEY_REQUEST_NOT_FOUND(404, "KEY003", "API Key 신청을 찾을 수 없습니다"),
|
API_KEY_REQUEST_NOT_FOUND(404, "KEY003", "API Key 신청을 찾을 수 없습니다"),
|
||||||
API_KEY_REQUEST_ALREADY_PROCESSED(409, "KEY004", "이미 처리된 신청입니다"),
|
API_KEY_REQUEST_ALREADY_PROCESSED(409, "KEY004", "이미 처리된 신청입니다"),
|
||||||
ENCRYPTION_ERROR(500, "KEY005", "암호화 처리 중 오류가 발생했습니다"),
|
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", "시스템 오류가 발생했습니다");
|
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
|
||||||
|
|
||||||
private final int status;
|
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("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
.requestMatchers("/actuator/**").permitAll()
|
.requestMatchers("/actuator/**").permitAll()
|
||||||
.requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
|
.requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
|
||||||
|
.requestMatchers("/gateway/**").permitAll()
|
||||||
.requestMatchers("/api/**").authenticated()
|
.requestMatchers("/api/**").authenticated()
|
||||||
.anyRequest().permitAll())
|
.anyRequest().permitAll())
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.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 com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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 org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface SnpServiceApiRepository extends JpaRepository<SnpServiceApi, Long> {
|
public interface SnpServiceApiRepository extends JpaRepository<SnpServiceApi, Long> {
|
||||||
|
|
||||||
List<SnpServiceApi> findByService(SnpService service);
|
List<SnpServiceApi> findByService(SnpService service);
|
||||||
|
|
||||||
List<SnpServiceApi> findByServiceServiceId(Long serviceId);
|
List<SnpServiceApi> findByServiceServiceId(Long serviceId);
|
||||||
|
|
||||||
|
Optional<SnpServiceApi> findByServiceServiceIdAndApiPathAndApiMethod(
|
||||||
|
Long serviceId, String apiPath, String apiMethod);
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user