Merge pull request 'feat: 로그인 제거 + 역할 토글 + 파티셔닝 최적화 (#35)' (#36) from feature/ISSUE-35-remove-login into develop

This commit is contained in:
HYOJIN 2026-04-13 09:31:27 +09:00
커밋 ab9e10a5a8
26개의 변경된 파일377개의 추가작업 그리고 467개의 파일을 삭제

파일 보기

@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased] ## [Unreleased]
### 추가
- 로그인 프로세스 제거 + ADMIN/MANAGER/USER 역할 토글 버튼 (#35)
- RoleGuard 컴포넌트로 관리자 전용 페이지 접근 제어 (#35)
- Request Logs 날짜 프리셋 버튼 (오늘/어제/최근7일/이번달/지난달) (#35)
- health_log 일별 파티셔닝 + 복합 인덱스 최적화 (#35)
### 변경
- SecurityConfig permitAll 전환, @PreAuthorize 전체 제거 (#35)
- X-User-Id 헤더 기반 사용자 식별로 전환 (#35)
- Request Logs 필터 영역 한 줄 통합, IP 필드 제거 (#35)
- PartitionService 범용화 (테이블명 파라미터) (#35)
- DataCleanupScheduler health_log DELETE → 파티션 DROP 전환 (#35)
## [2026-04-10] ## [2026-04-10]
### 추가 ### 추가

파일 보기

@ -81,6 +81,8 @@ CREATE TABLE IF NOT EXISTS snp_service_health_log (
CREATE INDEX idx_snp_health_log_service ON snp_service_health_log (service_id); CREATE INDEX idx_snp_health_log_service ON snp_service_health_log (service_id);
CREATE INDEX idx_snp_health_log_checked ON snp_service_health_log (checked_at); CREATE INDEX idx_snp_health_log_checked ON snp_service_health_log (checked_at);
CREATE INDEX idx_health_log_svc_checked ON snp_service_health_log (service_id, checked_at DESC);
CREATE INDEX idx_health_log_daily_uptime ON snp_service_health_log (service_id, checked_at, current_status);
-- ----------------------------------------------------------- -- -----------------------------------------------------------
-- 5. snp_service_api (서비스 API) -- 5. snp_service_api (서비스 API)

파일 보기

@ -0,0 +1,72 @@
-- =============================================================
-- SNP Connection Monitoring - Health Log 파티션 마이그레이션
-- snp_service_health_log → 일별 Range 파티션 전환
-- 스키마: common
-- =============================================================
-- 1. 백업
CREATE TABLE common.snp_service_health_log_backup AS
SELECT * FROM common.snp_service_health_log;
-- 2. 기존 테이블 삭제
DROP TABLE common.snp_service_health_log;
-- 3. 파티션 테이블 생성
CREATE TABLE common.snp_service_health_log (
log_id BIGSERIAL,
service_id BIGINT NOT NULL,
previous_status VARCHAR(10),
current_status VARCHAR(10) NOT NULL,
response_time INTEGER,
error_message TEXT,
checked_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (log_id, checked_at)
) PARTITION BY RANGE (checked_at);
-- 4. 인덱스 (파티션에 자동 상속)
CREATE INDEX idx_snp_health_log_service ON common.snp_service_health_log (service_id);
CREATE INDEX idx_snp_health_log_checked ON common.snp_service_health_log (checked_at);
CREATE INDEX idx_health_log_svc_checked ON common.snp_service_health_log (service_id, checked_at DESC);
CREATE INDEX idx_health_log_daily_uptime ON common.snp_service_health_log (service_id, checked_at, current_status);
-- 5. 오늘 + 미래 7일 파티션 생성 (실행 시점에 맞게 날짜 수정)
-- 예시: 2026-04-13 실행 기준
CREATE TABLE common.snp_service_health_log_20260413
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-13') TO ('2026-04-14');
CREATE TABLE common.snp_service_health_log_20260414
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-14') TO ('2026-04-15');
CREATE TABLE common.snp_service_health_log_20260415
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-15') TO ('2026-04-16');
CREATE TABLE common.snp_service_health_log_20260416
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-16') TO ('2026-04-17');
CREATE TABLE common.snp_service_health_log_20260417
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-17') TO ('2026-04-18');
CREATE TABLE common.snp_service_health_log_20260418
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-18') TO ('2026-04-19');
CREATE TABLE common.snp_service_health_log_20260419
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-19') TO ('2026-04-20');
CREATE TABLE common.snp_service_health_log_20260420
PARTITION OF common.snp_service_health_log
FOR VALUES FROM ('2026-04-20') TO ('2026-04-21');
-- 6. 데이터 복원 (컬럼 명시)
INSERT INTO common.snp_service_health_log (
log_id, service_id, previous_status, current_status, response_time, error_message, checked_at
) SELECT
log_id, service_id, previous_status, current_status, response_time, error_message, checked_at
FROM common.snp_service_health_log_backup
WHERE checked_at >= '2026-04-13';
-- 7. 시퀀스 리셋
SELECT setval(pg_get_serial_sequence('common.snp_service_health_log', 'log_id'),
(SELECT COALESCE(MAX(log_id), 0) FROM common.snp_service_health_log));
-- 8. 백업 삭제 (확인 후)
-- DROP TABLE common.snp_service_health_log_backup;

파일 보기

@ -1,10 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import ThemeProvider from './store/ThemeContext'; import ThemeProvider from './store/ThemeContext';
import AuthProvider from './store/AuthContext'; import AuthProvider from './store/AuthContext';
import AuthLayout from './layouts/AuthLayout';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
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 RequestLogDetailPage from './pages/monitoring/RequestLogDetailPage';
@ -22,6 +19,7 @@ import ApiStatsPage from './pages/statistics/ApiStatsPage';
import TenantStatsPage from './pages/statistics/TenantStatsPage'; import TenantStatsPage from './pages/statistics/TenantStatsPage';
import UsageTrendPage from './pages/statistics/UsageTrendPage'; import UsageTrendPage from './pages/statistics/UsageTrendPage';
import NotFoundPage from './pages/NotFoundPage'; import NotFoundPage from './pages/NotFoundPage';
import RoleGuard from './components/RoleGuard';
const BASE_PATH = '/snp-connection'; const BASE_PATH = '/snp-connection';
@ -31,11 +29,6 @@ const App = () => {
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<Routes> <Routes>
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
@ -50,13 +43,12 @@ const App = () => {
<Route path="/statistics/usage-trend" element={<UsageTrendPage />} /> <Route path="/statistics/usage-trend" element={<UsageTrendPage />} />
<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={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></RoleGuard>} />
<Route path="/admin/services" element={<ServicesPage />} /> <Route path="/admin/services" element={<RoleGuard allowedRoles={['ADMIN']}><ServicesPage /></RoleGuard>} />
<Route path="/admin/users" element={<UsersPage />} /> <Route path="/admin/users" element={<RoleGuard allowedRoles={['ADMIN']}><UsersPage /></RoleGuard>} />
<Route path="/admin/tenants" element={<TenantsPage />} /> <Route path="/admin/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Route>
</Routes> </Routes>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>

파일 보기

@ -1,22 +1,5 @@
import { Navigate, Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const ProtectedRoute = () => { const ProtectedRoute = () => <Outlet />;
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent dark:border-blue-400 dark:border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
};
export default ProtectedRoute; export default ProtectedRoute;

파일 보기

@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
interface RoleGuardProps {
allowedRoles: string[];
children: React.ReactNode;
}
const RoleGuard = ({ allowedRoles, children }: RoleGuardProps) => {
const { user } = useAuth();
const navigate = useNavigate();
if (!user || !allowedRoles.includes(user.role)) {
return (
<div className="max-w-7xl mx-auto flex flex-col items-center justify-center py-32">
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-lg p-10 text-center max-w-md">
<div className="w-16 h-16 mx-auto mb-5 rounded-full bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700/30 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2"> </h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
<span className="font-semibold text-gray-700 dark:text-gray-300">{allowedRoles.join(', ')}</span> .
<br /> : <span className="font-semibold text-gray-700 dark:text-gray-300">{user?.role || '-'}</span>
</p>
<button
onClick={() => navigate('/dashboard')}
className="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
>
</button>
</div>
</div>
);
}
return <>{children}</>;
};
export default RoleGuard;

파일 보기

@ -1,11 +0,0 @@
import { Outlet } from 'react-router-dom';
const AuthLayout = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<Outlet />
</div>
);
};
export default AuthLayout;

파일 보기

@ -46,8 +46,10 @@ const navGroups: NavGroup[] = [
}, },
]; ];
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
const MainLayout = () => { const MainLayout = () => {
const { user, logout } = useAuth(); const { user, setRole } = useAuth();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true, Monitoring: true,
@ -60,10 +62,6 @@ const MainLayout = () => {
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] })); setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
}; };
const handleLogout = async () => {
await logout();
};
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER'; const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
return ( return (
@ -170,16 +168,22 @@ const MainLayout = () => {
</svg> </svg>
)} )}
</button> </button>
<span className="text-sm text-gray-700 dark:text-gray-300">{user?.userName}</span> <span className="text-sm text-gray-500 dark:text-gray-400">Role:</span>
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"> <div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
{user?.role} {ROLES.map((role) => (
</span>
<button <button
onClick={handleLogout} key={role}
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" onClick={() => setRole(role)}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
user?.role === role
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
> >
Logout {role}
</button> </button>
))}
</div>
</div> </div>
</header> </header>

파일 보기

@ -1,93 +0,0 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const LoginPage = () => {
const navigate = useNavigate();
const { login } = useAuth();
const [loginId, setLoginId] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (!loginId.trim() || !password.trim()) {
setError('아이디와 비밀번호를 입력해주세요.');
return;
}
setIsSubmitting(true);
try {
await login(loginId, password);
navigate('/dashboard', { replace: true });
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('로그인에 실패했습니다.');
}
} finally {
setIsSubmitting(false);
}
};
return (
<div className="w-full max-w-md">
<div className="rounded-xl bg-white dark:bg-gray-800 px-8 py-10 shadow-lg">
<h1 className="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">
SNP Connection Monitoring
</h1>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="loginId" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
id="loginId"
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="아이디를 입력하세요"
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="비밀번호를 입력하세요"
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isSubmitting ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
</div>
);
};
export default LoginPage;

파일 보기

@ -26,14 +26,13 @@ const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY',
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE']; const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
const getTodayString = (): string => { const formatDate = (d: Date): string => {
const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
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 getToday = (): string => formatDate(new Date());
const getTodayString = getToday;
const formatDateTime = (dateStr: string): string => { const formatDateTime = (dateStr: string): string => {
const d = new Date(dateStr); const d = new Date(dateStr);
const year = d.getFullYear(); const year = d.getFullYear();
@ -50,10 +49,10 @@ const RequestLogsPage = () => {
const [startDate, setStartDate] = useState(getTodayString()); const [startDate, setStartDate] = useState(getTodayString());
const [endDate, setEndDate] = useState(getTodayString()); const [endDate, setEndDate] = useState(getTodayString());
const [datePreset, setDatePreset] = useState('오늘');
const [serviceId, setServiceId] = useState(''); const [serviceId, setServiceId] = useState('');
const [requestStatus, setRequestStatus] = useState(''); const [requestStatus, setRequestStatus] = useState('');
const [requestMethod, setRequestMethod] = useState(''); const [requestMethod, setRequestMethod] = useState('');
const [requestIp, setRequestIp] = useState('');
const [services, setServices] = useState<ServiceInfo[]>([]); const [services, setServices] = useState<ServiceInfo[]>([]);
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null); const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
@ -84,7 +83,6 @@ const RequestLogsPage = () => {
serviceId: serviceId ? Number(serviceId) : undefined, serviceId: serviceId ? Number(serviceId) : undefined,
requestStatus: requestStatus || undefined, requestStatus: requestStatus || undefined,
requestMethod: requestMethod || undefined, requestMethod: requestMethod || undefined,
requestIp: requestIp || undefined,
page, page,
size: DEFAULT_PAGE_SIZE, size: DEFAULT_PAGE_SIZE,
}; };
@ -104,10 +102,10 @@ const RequestLogsPage = () => {
const handleReset = () => { const handleReset = () => {
setStartDate(getTodayString()); setStartDate(getTodayString());
setEndDate(getTodayString()); setEndDate(getTodayString());
setDatePreset('오늘');
setServiceId(''); setServiceId('');
setRequestStatus(''); setRequestStatus('');
setRequestMethod(''); setRequestMethod('');
setRequestIp('');
setCurrentPage(0); setCurrentPage(0);
}; };
@ -169,45 +167,68 @@ const RequestLogsPage = () => {
{/* Search Form */} {/* Search Form */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div> <div className="md:col-span-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
<div className="flex items-center gap-2 flex-wrap mb-2">
{([
{ label: '오늘', fn: () => { const t = getToday(); setStartDate(t); setEndDate(t); setDatePreset('오늘'); } },
{ label: '어제', fn: () => { const d = new Date(); d.setDate(d.getDate() - 1); const y = formatDate(d); setStartDate(y); setEndDate(y); setDatePreset('어제'); } },
{ label: '최근 7일', fn: () => { const d = new Date(); d.setDate(d.getDate() - 6); setStartDate(formatDate(d)); setEndDate(getToday()); setDatePreset('최근 7일'); } },
{ label: '이번 달', fn: () => { const d = new Date(); setStartDate(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`); setEndDate(getToday()); setDatePreset('이번 달'); } },
{ label: '지난 달', fn: () => { const d = new Date(); d.setMonth(d.getMonth() - 1); const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; const e = new Date(d.getFullYear(), d.getMonth() + 1, 0); setStartDate(s); setEndDate(formatDate(e)); setDatePreset('지난 달'); } },
{ label: '직접 선택', fn: () => { setDatePreset('직접 선택'); } },
]).map((btn) => (
<button
key={btn.label}
type="button"
onClick={btn.fn}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
datePreset === btn.label
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400'
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
{btn.label}
</button>
))}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="date" type="date"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => { setStartDate(e.target.value); setDatePreset('직접 선택'); }}
className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/> />
<span className="text-gray-500 dark:text-gray-400">~</span> <span className="text-gray-500 dark:text-gray-400">~</span>
<input <input
type="date" type="date"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => { setEndDate(e.target.value); setDatePreset('직접 선택'); }}
className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
</div> </div>
</div>
<div className="flex items-end gap-3 flex-wrap">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label> <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"></label>
<select <select
value={serviceId} value={serviceId}
onChange={(e) => setServiceId(e.target.value)} onChange={(e) => setServiceId(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
> >
<option value=""></option> <option value=""></option>
{services.map((s) => ( {services.map((s) => (
<option key={s.serviceId} value={s.serviceId}> <option key={s.serviceId} value={s.serviceId}>{s.serviceName}</option>
{s.serviceName}
</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label> <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"></label>
<select <select
value={requestStatus} value={requestStatus}
onChange={(e) => setRequestStatus(e.target.value)} onChange={(e) => setRequestStatus(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
> >
<option value=""></option> <option value=""></option>
{REQUEST_STATUSES.map((s) => ( {REQUEST_STATUSES.map((s) => (
@ -215,14 +236,12 @@ const RequestLogsPage = () => {
))} ))}
</select> </select>
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">HTTP Method</label> <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Method</label>
<select <select
value={requestMethod} value={requestMethod}
onChange={(e) => setRequestMethod(e.target.value)} onChange={(e) => setRequestMethod(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
> >
<option value=""></option> <option value=""></option>
{HTTP_METHODS.map((m) => ( {HTTP_METHODS.map((m) => (
@ -230,17 +249,7 @@ const RequestLogsPage = () => {
))} ))}
</select> </select>
</div> </div>
<div> <div className="flex items-end gap-2 ml-auto">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IP</label>
<input
type="text"
value={requestIp}
onChange={(e) => setRequestIp(e.target.value)}
placeholder="IP 주소"
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 <button
onClick={() => handleSearch(0)} onClick={() => handleSearch(0)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"

파일 보기

@ -2,93 +2,24 @@ import type { ApiResponse } from '../types/api';
const BASE_URL = '/snp-connection/api'; const BASE_URL = '/snp-connection/api';
let isRefreshing = false; let currentUserId: number = 1;
let refreshPromise: Promise<boolean> | null = null;
const getAccessToken = (): string | null => { export const setApiClientUserId = (userId: number) => {
return localStorage.getItem('snp_access_token'); currentUserId = userId;
};
const getRefreshToken = (): string | null => {
return localStorage.getItem('snp_refresh_token');
};
const clearStorage = () => {
localStorage.removeItem('snp_access_token');
localStorage.removeItem('snp_refresh_token');
localStorage.removeItem('snp_user');
};
const attemptRefresh = async (): Promise<boolean> => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) return false;
const data = await response.json() as ApiResponse<{ accessToken: string }>;
if (data.success && data.data?.accessToken) {
localStorage.setItem('snp_access_token', data.data.accessToken);
return true;
}
return false;
} catch {
return false;
}
};
const handleTokenRefresh = async (): Promise<boolean> => {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = attemptRefresh().finally(() => {
isRefreshing = false;
refreshPromise = null;
});
return refreshPromise;
}; };
const request = async <T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> => { const request = async <T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> => {
const token = getAccessToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-User-Id': String(currentUserId),
...options.headers as Record<string, string>, ...options.headers as Record<string, string>,
}; };
if (token) { const response = await fetch(`${BASE_URL}${url}`, {
headers['Authorization'] = `Bearer ${token}`;
}
let response = await fetch(`${BASE_URL}${url}`, {
...options, ...options,
headers, headers,
}); });
if (response.status === 401) {
const refreshed = await handleTokenRefresh();
if (refreshed) {
const newToken = getAccessToken();
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(`${BASE_URL}${url}`, {
...options,
headers,
});
} else {
clearStorage();
window.location.href = '/snp-connection/login';
return { success: false, message: 'Authentication failed' };
}
}
const data = await response.json() as ApiResponse<T>; const data = await response.json() as ApiResponse<T>;
return data; return data;
}; };

파일 보기

@ -1,67 +1,2 @@
import { post } from './apiClient'; // Auth service removed - login/logout no longer needed.
import type { LoginRequest, LoginResponse, User } from '../types/auth'; // Role is managed via AuthContext's setRole function.
const TOKEN_KEY = 'snp_access_token';
const REFRESH_TOKEN_KEY = 'snp_refresh_token';
const USER_KEY = 'snp_user';
export const login = async (req: LoginRequest): Promise<LoginResponse> => {
const response = await post<LoginResponse>('/auth/login', req);
if (!response.success || !response.data) {
throw new Error(response.message || '로그인에 실패했습니다.');
}
const data = response.data;
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify({
loginId: data.loginId,
userName: data.userName,
role: data.role,
}));
return data;
};
export const logout = async (): Promise<void> => {
try {
await post('/auth/logout');
} finally {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
};
export const refreshToken = async (): Promise<void> => {
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!storedRefreshToken) {
throw new Error('No refresh token available');
}
const response = await post<{ accessToken: string }>('/auth/refresh', {
refreshToken: storedRefreshToken,
});
if (!response.success || !response.data) {
throw new Error('Token refresh failed');
}
localStorage.setItem(TOKEN_KEY, response.data.accessToken);
};
export const getStoredUser = (): User | null => {
const userStr = localStorage.getItem(USER_KEY);
if (!userStr) return null;
try {
return JSON.parse(userStr) as User;
} catch {
return null;
}
};
export const getAccessToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};

파일 보기

@ -1,17 +1,13 @@
import { createContext, useState, useEffect, useCallback } from 'react'; import { createContext, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { User } from '../types/auth'; import type { User } from '../types/auth';
import * as authService from '../services/authService'; import { setApiClientUserId } from '../services/apiClient';
interface AuthState { interface AuthContextValue {
user: User | null; user: User | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
} setRole: (role: 'ADMIN' | 'MANAGER' | 'USER') => void;
interface AuthContextValue extends AuthState {
login: (loginId: string, password: string) => Promise<void>;
logout: () => Promise<void>;
} }
export const AuthContext = createContext<AuthContextValue | null>(null); export const AuthContext = createContext<AuthContextValue | null>(null);
@ -20,56 +16,32 @@ interface AuthProviderProps {
children: ReactNode; children: ReactNode;
} }
const ROLE_USERS: Record<string, User> = {
ADMIN: { userId: 1, loginId: 'admin', userName: '관리자', role: 'ADMIN' },
MANAGER: { userId: 7, loginId: 'manager', userName: '매니저', role: 'MANAGER' },
USER: { userId: 2, loginId: 'user', userName: '사용자', role: 'USER' },
};
const getInitialUser = (): User => {
const savedRole = localStorage.getItem('snp-role') as 'ADMIN' | 'MANAGER' | 'USER' | null;
const u = ROLE_USERS[savedRole || 'ADMIN'];
setApiClientUserId(u.userId);
return u;
};
const AuthProvider = ({ children }: AuthProviderProps) => { const AuthProvider = ({ children }: AuthProviderProps) => {
const [state, setState] = useState<AuthState>({ const [user, setUser] = useState<User>(getInitialUser);
user: null,
isAuthenticated: false,
isLoading: true,
});
useEffect(() => { const setRole = (role: 'ADMIN' | 'MANAGER' | 'USER') => {
const storedUser = authService.getStoredUser(); localStorage.setItem('snp-role', role);
const token = authService.getAccessToken(); const u = ROLE_USERS[role];
setUser(u);
if (storedUser && token) { setApiClientUserId(u.userId);
setState({ window.location.reload();
user: storedUser, };
isAuthenticated: true,
isLoading: false,
});
} else {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
}, []);
const login = useCallback(async (loginId: string, password: string) => {
const response = await authService.login({ loginId, password });
setState({
user: {
loginId: response.loginId,
userName: response.userName,
role: response.role,
},
isAuthenticated: true,
isLoading: false,
});
}, []);
const logout = useCallback(async () => {
await authService.logout();
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}, []);
return ( return (
<AuthContext.Provider value={{ ...state, login, logout }}> <AuthContext.Provider value={{ user, isAuthenticated: true, isLoading: false, setRole }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

파일 보기

@ -1,18 +1,5 @@
export interface LoginRequest {
loginId: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
loginId: string;
userName: string;
role: string;
expiresIn: number;
}
export interface User { export interface User {
userId: number;
loginId: string; loginId: string;
userName: string; userName: string;
role: string; role: string;

파일 보기

@ -9,10 +9,10 @@ import com.gcsc.connection.apikey.dto.UpdatePermissionsRequest;
import com.gcsc.connection.apikey.service.ApiKeyPermissionService; import com.gcsc.connection.apikey.service.ApiKeyPermissionService;
import com.gcsc.connection.apikey.service.ApiKeyService; import com.gcsc.connection.apikey.service.ApiKeyService;
import com.gcsc.connection.common.dto.ApiResponse; import com.gcsc.connection.common.dto.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@ -34,6 +34,7 @@ public class ApiKeyController {
private final ApiKeyService apiKeyService; private final ApiKeyService apiKeyService;
private final ApiKeyPermissionService apiKeyPermissionService; private final ApiKeyPermissionService apiKeyPermissionService;
private final HttpServletRequest request;
/** /**
* API Key 목록 조회 * API Key 목록 조회
@ -49,7 +50,6 @@ public class ApiKeyController {
* 전체 API Key 목록 조회 (관리자용) * 전체 API Key 목록 조회 (관리자용)
*/ */
@GetMapping("/all") @GetMapping("/all")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<List<ApiKeyResponse>>> getAllKeys() { public ResponseEntity<ApiResponse<List<ApiKeyResponse>>> getAllKeys() {
List<ApiKeyResponse> keys = apiKeyService.getAllKeys(); List<ApiKeyResponse> keys = apiKeyService.getAllKeys();
return ResponseEntity.ok(ApiResponse.ok(keys)); return ResponseEntity.ok(ApiResponse.ok(keys));
@ -59,7 +59,6 @@ public class ApiKeyController {
* API Key 상세 조회 (복호화된 포함, 관리자 전용) * API Key 상세 조회 (복호화된 포함, 관리자 전용)
*/ */
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ApiKeyDetailResponse>> getKeyDetail(@PathVariable Long id) { public ResponseEntity<ApiResponse<ApiKeyDetailResponse>> getKeyDetail(@PathVariable Long id) {
ApiKeyDetailResponse detail = apiKeyService.getKeyDetail(id); ApiKeyDetailResponse detail = apiKeyService.getKeyDetail(id);
return ResponseEntity.ok(ApiResponse.ok(detail)); return ResponseEntity.ok(ApiResponse.ok(detail));
@ -69,7 +68,6 @@ public class ApiKeyController {
* API Key 생성 (관리자 전용) * API Key 생성 (관리자 전용)
*/ */
@PostMapping @PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> createKey( public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> createKey(
@RequestBody @Valid CreateApiKeyRequest request) { @RequestBody @Valid CreateApiKeyRequest request) {
Long userId = getCurrentUserId(); Long userId = getCurrentUserId();
@ -99,7 +97,6 @@ public class ApiKeyController {
* API Key 권한 수정 (관리자/매니저 전용) * API Key 권한 수정 (관리자/매니저 전용)
*/ */
@PutMapping("/{id}/permissions") @PutMapping("/{id}/permissions")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<List<PermissionResponse>>> updatePermissions( public ResponseEntity<ApiResponse<List<PermissionResponse>>> updatePermissions(
@PathVariable Long id, @PathVariable Long id,
@RequestBody @Valid UpdatePermissionsRequest request) { @RequestBody @Valid UpdatePermissionsRequest request) {
@ -109,6 +106,15 @@ public class ApiKeyController {
} }
private Long getCurrentUserId() { private Long getCurrentUserId() {
String userIdHeader = request.getHeader("X-User-Id");
if (userIdHeader != null && !userIdHeader.isBlank()) {
return Long.parseLong(userIdHeader);
}
// fallback: SecurityContext
try {
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
} catch (Exception e) {
return 1L; // default admin
}
} }
} }

파일 보기

@ -6,10 +6,10 @@ import com.gcsc.connection.apikey.dto.ApiKeyRequestResponse;
import com.gcsc.connection.apikey.dto.ApiKeyRequestReviewDto; import com.gcsc.connection.apikey.dto.ApiKeyRequestReviewDto;
import com.gcsc.connection.apikey.service.ApiKeyRequestService; import com.gcsc.connection.apikey.service.ApiKeyRequestService;
import com.gcsc.connection.common.dto.ApiResponse; import com.gcsc.connection.common.dto.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@ -31,6 +31,7 @@ import java.util.List;
public class ApiKeyRequestController { public class ApiKeyRequestController {
private final ApiKeyRequestService apiKeyRequestService; private final ApiKeyRequestService apiKeyRequestService;
private final HttpServletRequest request;
/** /**
* API Key 신청 생성 * API Key 신청 생성
@ -57,7 +58,6 @@ public class ApiKeyRequestController {
* 신청 목록 조회 (관리자/매니저용) * 신청 목록 조회 (관리자/매니저용)
*/ */
@GetMapping @GetMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<List<ApiKeyRequestResponse>>> getRequests( public ResponseEntity<ApiResponse<List<ApiKeyRequestResponse>>> getRequests(
@RequestParam(required = false) String status) { @RequestParam(required = false) String status) {
List<ApiKeyRequestResponse> responses; List<ApiKeyRequestResponse> responses;
@ -73,7 +73,6 @@ public class ApiKeyRequestController {
* 신청 심사 (승인/거절) * 신청 심사 (승인/거절)
*/ */
@PutMapping("/{id}/review") @PutMapping("/{id}/review")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> reviewRequest( public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> reviewRequest(
@PathVariable Long id, @PathVariable Long id,
@RequestBody @Valid ApiKeyRequestReviewDto request) { @RequestBody @Valid ApiKeyRequestReviewDto request) {
@ -86,6 +85,14 @@ public class ApiKeyRequestController {
} }
private Long getCurrentUserId() { private Long getCurrentUserId() {
String userIdHeader = request.getHeader("X-User-Id");
if (userIdHeader != null && !userIdHeader.isBlank()) {
return Long.parseLong(userIdHeader);
}
try {
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
} catch (Exception e) {
return 1L;
}
} }
} }

파일 보기

@ -1,7 +1,5 @@
package com.gcsc.connection.config; package com.gcsc.connection.config;
import com.gcsc.connection.auth.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@ -13,16 +11,12 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity // @EnableMethodSecurity -- disabled (no login)
@RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@ -42,9 +36,8 @@ public class SecurityConfig {
.requestMatchers("/actuator/**").permitAll() .requestMatchers("/actuator/**").permitAll()
.requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll() .requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
.requestMatchers("/gateway/**").permitAll() .requestMatchers("/gateway/**").permitAll()
.requestMatchers("/api/**").authenticated() .requestMatchers("/api/**").permitAll()
.anyRequest().permitAll()) .anyRequest().permitAll());
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

파일 보기

@ -12,8 +12,14 @@ import org.springframework.web.bind.annotation.GetMapping;
@Controller @Controller
public class WebViewController { public class WebViewController {
@GetMapping({"/", "/login", "/dashboard", "/dashboard/**", @GetMapping("/")
"/monitoring/**", "/apikeys", "/apikeys/**", public String root() {
return "redirect:/dashboard";
}
@GetMapping({"/dashboard", "/dashboard/**",
"/monitoring/**", "/statistics/**",
"/apikeys", "/apikeys/**",
"/admin/**"}) "/admin/**"})
public String forward() { public String forward() {
return "forward:/index.html"; return "forward:/index.html";

파일 보기

@ -7,7 +7,6 @@ import com.gcsc.connection.monitoring.dto.ServiceStatusDetailResponse;
import com.gcsc.connection.monitoring.service.HeartbeatService; import com.gcsc.connection.monitoring.service.HeartbeatService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -68,7 +67,6 @@ public class HeartbeatController {
* 수동 헬스체크 실행 * 수동 헬스체크 실행
*/ */
@PostMapping("/{serviceId}/check") @PostMapping("/{serviceId}/check")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<HeartbeatStatusResponse>> checkService( public ResponseEntity<ApiResponse<HeartbeatStatusResponse>> checkService(
@PathVariable Long serviceId) { @PathVariable Long serviceId) {
HeartbeatStatusResponse result = heartbeatService.checkService(serviceId); HeartbeatStatusResponse result = heartbeatService.checkService(serviceId);

파일 보기

@ -2,8 +2,10 @@ package com.gcsc.connection.monitoring.entity;
import com.gcsc.connection.service.entity.SnpService; import com.gcsc.connection.service.entity.SnpService;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.ConstraintMode;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
@ -28,7 +30,8 @@ public class SnpServiceHealthLog {
private Long logId; private Long logId;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "service_id", nullable = false) @JoinColumn(name = "service_id", nullable = false,
foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private SnpService service; private SnpService service;
@Column(name = "previous_status", length = 10) @Column(name = "previous_status", length = 10)

파일 보기

@ -1,38 +1,23 @@
package com.gcsc.connection.monitoring.scheduler; package com.gcsc.connection.monitoring.scheduler;
import com.gcsc.connection.monitoring.repository.SnpServiceHealthLogRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class DataCleanupScheduler { public class DataCleanupScheduler {
private final SnpServiceHealthLogRepository healthLogRepository;
@Value("${app.retention.health-log-days:90}")
private int healthLogRetentionDays;
/** /**
* 매일 02:00 실행 - 오래된 데이터 정리 * 매일 02:00 실행 - 오래된 데이터 정리
* Health log는 PartitionManageScheduler에서 파티션 DROP으로 관리
*/ */
@Scheduled(cron = "0 0 2 * * *") @Scheduled(cron = "0 0 2 * * *")
@Transactional
public void cleanupOldData() { public void cleanupOldData() {
log.info("데이터 정리 시작"); log.info("데이터 정리 시작");
// Health log는 PartitionManageScheduler에서 파티션 DROP으로 관리
// Health log 정리 log.info("데이터 정리 완료 (health_log는 파티션으로 관리)");
LocalDateTime healthCutoff = LocalDateTime.now().minusDays(healthLogRetentionDays);
int deletedHealthLogs = healthLogRepository.deleteOlderThan(healthCutoff);
log.info("Health log 정리 완료: {}건 삭제 (기준: {}일 이전)", deletedHealthLogs, healthLogRetentionDays);
log.info("데이터 정리 완료");
} }
} }

파일 보기

@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
@ -27,6 +28,10 @@ public class PartitionManageScheduler {
@Value("${app.retention.request-log-days:90}") @Value("${app.retention.request-log-days:90}")
private int retentionDays; private int retentionDays;
private static final String HEALTH_LOG_TABLE = "common.snp_service_health_log";
private static final String HEALTH_LOG_PREFIX = "snp_service_health_log_";
private static final int ADVANCE_DAYS = 7;
/** /**
* 매월 1일 00:00 실행 - 파티션 생성/삭제 * 매월 1일 00:00 실행 - 파티션 생성/삭제
*/ */
@ -64,6 +69,45 @@ public class PartitionManageScheduler {
log.info("파티션 관리 완료"); log.info("파티션 관리 완료");
} }
/**
* 매일 00:30 실행 - health_log 일별 파티션 관리
*/
@Scheduled(cron = "0 30 0 * * *")
public void manageHealthLogPartitions() {
if (!partitionEnabled) {
return;
}
log.info("Health log 파티션 관리 시작");
LocalDate today = LocalDate.now();
// 미래 7일 파티션 생성
for (int i = 0; i <= ADVANCE_DAYS; i++) {
partitionService.createDailyPartition(HEALTH_LOG_TABLE, today.plusDays(i));
}
// 보관기간 이전 파티션 삭제
LocalDate cutoff = today.minusDays(retentionDays);
List<String> existing = partitionService.getExistingPartitions(HEALTH_LOG_PREFIX);
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyyMMdd");
String cutoffStr = HEALTH_LOG_PREFIX + cutoff.format(fmt);
for (String partition : existing) {
if (partition.compareTo(cutoffStr) < 0) {
try {
LocalDate partDate = LocalDate.parse(
partition.replace(HEALTH_LOG_PREFIX, ""), fmt);
partitionService.dropDailyPartition(HEALTH_LOG_TABLE, partDate);
} catch (Exception e) {
log.warn("파티션 날짜 파싱 실패: {}", partition);
}
}
}
log.info("Health log 파티션 관리 완료");
}
private YearMonth parsePartitionYearMonth(String partitionName) { private YearMonth parsePartitionYearMonth(String partitionName) {
try { try {
String suffix = partitionName.replace("snp_api_request_log_", ""); String suffix = partitionName.replace("snp_api_request_log_", "");

파일 보기

@ -57,11 +57,53 @@ public class PartitionService {
} }
/** /**
* 기존 파티션 목록 조회 * 기존 파티션 목록 조회 (API 요청 로그용 - 하위 호환)
*/ */
public List<String> getExistingPartitions() { public List<String> getExistingPartitions() {
return getExistingPartitions(PARTITION_PREFIX);
}
/**
* 기존 파티션 목록 조회 (테이블 접두사 지정)
*/
public List<String> getExistingPartitions(String tablePrefix) {
String sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'common' AND tablename LIKE '" String sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'common' AND tablename LIKE '"
+ PARTITION_PREFIX + "%' ORDER BY tablename"; + tablePrefix + "%' ORDER BY tablename";
return jdbcTemplate.queryForList(sql, String.class); return jdbcTemplate.queryForList(sql, String.class);
} }
/**
* 일별 파티션 생성
*/
public void createDailyPartition(String tableName, LocalDate date) {
String partitionName = tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
LocalDate nextDay = date.plusDays(1);
String sql = String.format(
"CREATE TABLE IF NOT EXISTS %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
partitionName, tableName, date, nextDay);
try {
jdbcTemplate.execute(sql);
log.info("일별 파티션 생성 완료: {}", partitionName);
} catch (Exception e) {
log.error("일별 파티션 생성 실패: {}", partitionName, e);
}
}
/**
* 일별 파티션 삭제
*/
public void dropDailyPartition(String tableName, LocalDate date) {
String partitionName = tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String sql = String.format("DROP TABLE IF EXISTS %s", partitionName);
try {
jdbcTemplate.execute(sql);
log.info("일별 파티션 삭제 완료: {}", partitionName);
} catch (Exception e) {
log.error("일별 파티션 삭제 실패: {}", partitionName, e);
}
}
} }

파일 보기

@ -10,7 +10,6 @@ import com.gcsc.connection.service.service.ServiceManagementService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -44,7 +43,6 @@ public class ServiceController {
* 서비스 생성 * 서비스 생성
*/ */
@PostMapping @PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ServiceResponse>> createService( public ResponseEntity<ApiResponse<ServiceResponse>> createService(
@RequestBody @Valid CreateServiceRequest request) { @RequestBody @Valid CreateServiceRequest request) {
ServiceResponse service = serviceManagementService.createService(request); ServiceResponse service = serviceManagementService.createService(request);
@ -55,7 +53,6 @@ public class ServiceController {
* 서비스 수정 * 서비스 수정
*/ */
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ServiceResponse>> updateService( public ResponseEntity<ApiResponse<ServiceResponse>> updateService(
@PathVariable Long id, @PathVariable Long id,
@RequestBody @Valid UpdateServiceRequest request) { @RequestBody @Valid UpdateServiceRequest request) {
@ -77,7 +74,6 @@ public class ServiceController {
* 서비스 API 생성 * 서비스 API 생성
*/ */
@PostMapping("/{id}/apis") @PostMapping("/{id}/apis")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ServiceApiResponse>> createServiceApi( public ResponseEntity<ApiResponse<ServiceApiResponse>> createServiceApi(
@PathVariable Long id, @PathVariable Long id,
@RequestBody @Valid CreateServiceApiRequest request) { @RequestBody @Valid CreateServiceApiRequest request) {

파일 보기

@ -8,7 +8,6 @@ import com.gcsc.connection.tenant.service.TenantService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -33,7 +32,6 @@ public class TenantController {
* 전체 테넌트 목록 조회 * 전체 테넌트 목록 조회
*/ */
@GetMapping @GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<List<TenantResponse>>> getTenants() { public ResponseEntity<ApiResponse<List<TenantResponse>>> getTenants() {
List<TenantResponse> tenants = tenantService.getTenants(); List<TenantResponse> tenants = tenantService.getTenants();
return ResponseEntity.ok(ApiResponse.ok(tenants)); return ResponseEntity.ok(ApiResponse.ok(tenants));
@ -43,7 +41,6 @@ public class TenantController {
* 테넌트 생성 * 테넌트 생성
*/ */
@PostMapping @PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<TenantResponse>> createTenant( public ResponseEntity<ApiResponse<TenantResponse>> createTenant(
@RequestBody @Valid CreateTenantRequest request) { @RequestBody @Valid CreateTenantRequest request) {
TenantResponse tenant = tenantService.createTenant(request); TenantResponse tenant = tenantService.createTenant(request);
@ -54,7 +51,6 @@ public class TenantController {
* 테넌트 수정 * 테넌트 수정
*/ */
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<TenantResponse>> updateTenant( public ResponseEntity<ApiResponse<TenantResponse>> updateTenant(
@PathVariable Long id, @PathVariable Long id,
@RequestBody @Valid UpdateTenantRequest request) { @RequestBody @Valid UpdateTenantRequest request) {

파일 보기

@ -9,7 +9,6 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -36,7 +35,6 @@ public class UserController {
* 전체 사용자 목록 조회 * 전체 사용자 목록 조회
*/ */
@GetMapping @GetMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<List<UserResponse>>> getUsers() { public ResponseEntity<ApiResponse<List<UserResponse>>> getUsers() {
List<UserResponse> users = userService.getUsers(); List<UserResponse> users = userService.getUsers();
return ResponseEntity.ok(ApiResponse.ok(users)); return ResponseEntity.ok(ApiResponse.ok(users));
@ -46,7 +44,6 @@ public class UserController {
* 사용자 생성 * 사용자 생성
*/ */
@PostMapping @PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<UserResponse>> createUser( public ResponseEntity<ApiResponse<UserResponse>> createUser(
@RequestBody @Valid CreateUserRequest request) { @RequestBody @Valid CreateUserRequest request) {
UserResponse user = userService.createUser(request); UserResponse user = userService.createUser(request);
@ -57,7 +54,6 @@ public class UserController {
* 사용자 단건 조회 (ADMIN/MANAGER 또는 본인) * 사용자 단건 조회 (ADMIN/MANAGER 또는 본인)
*/ */
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long id) { public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long id) {
String currentUserId = SecurityContextHolder.getContext().getAuthentication().getName(); String currentUserId = SecurityContextHolder.getContext().getAuthentication().getName();
if (!isAdminOrManager() && !currentUserId.equals(String.valueOf(id))) { if (!isAdminOrManager() && !currentUserId.equals(String.valueOf(id))) {
@ -86,7 +82,6 @@ public class UserController {
* 사용자 비활성화 (소프트 삭제) * 사용자 비활성화 (소프트 삭제)
*/ */
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<Void>> deactivateUser(@PathVariable Long id) { public ResponseEntity<ApiResponse<Void>> deactivateUser(@PathVariable Long id) {
userService.deactivateUser(id); userService.deactivateUser(id);
return ResponseEntity.ok(ApiResponse.ok(null, "사용자가 비활성화되었습니다")); return ResponseEntity.ok(ApiResponse.ok(null, "사용자가 비활성화되었습니다"));