generated from gc/template-java-maven
Compare commits
5 커밋
bfaf5e9b97
...
ab9e10a5a8
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| ab9e10a5a8 | |||
| dd80aca6ba | |||
| 2eebf2c83e | |||
| 765d0e01c6 | |||
| 97e5a24343 |
@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
||||
|
||||
## [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]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -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_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)
|
||||
|
||||
72
docs/schema/health_log_partition_migration.sql
Normal file
72
docs/schema/health_log_partition_migration.sql
Normal file
@ -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 ThemeProvider from './store/ThemeContext';
|
||||
import AuthProvider from './store/AuthContext';
|
||||
import AuthLayout from './layouts/AuthLayout';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import RequestLogsPage from './pages/monitoring/RequestLogsPage';
|
||||
import RequestLogDetailPage from './pages/monitoring/RequestLogDetailPage';
|
||||
@ -22,6 +19,7 @@ import ApiStatsPage from './pages/statistics/ApiStatsPage';
|
||||
import TenantStatsPage from './pages/statistics/TenantStatsPage';
|
||||
import UsageTrendPage from './pages/statistics/UsageTrendPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import RoleGuard from './components/RoleGuard';
|
||||
|
||||
const BASE_PATH = '/snp-connection';
|
||||
|
||||
@ -31,31 +29,25 @@ const App = () => {
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
|
||||
<Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
|
||||
<Route path="/monitoring/service-status" element={<ServiceStatusPage />} />
|
||||
<Route path="/monitoring/service-status/:serviceId" element={<ServiceStatusDetailPage />} />
|
||||
<Route path="/statistics/services" element={<ServiceStatsPage />} />
|
||||
<Route path="/statistics/users" element={<UserStatsPage />} />
|
||||
<Route path="/statistics/apis" element={<ApiStatsPage />} />
|
||||
<Route path="/statistics/tenants" element={<TenantStatsPage />} />
|
||||
<Route path="/statistics/usage-trend" element={<UsageTrendPage />} />
|
||||
<Route path="/apikeys/my-keys" element={<MyKeysPage />} />
|
||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||
<Route path="/apikeys/admin" element={<KeyAdminPage />} />
|
||||
<Route path="/admin/services" element={<ServicesPage />} />
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
<Route path="/admin/tenants" element={<TenantsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
|
||||
<Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
|
||||
<Route path="/monitoring/service-status" element={<ServiceStatusPage />} />
|
||||
<Route path="/monitoring/service-status/:serviceId" element={<ServiceStatusDetailPage />} />
|
||||
<Route path="/statistics/services" element={<ServiceStatsPage />} />
|
||||
<Route path="/statistics/users" element={<UserStatsPage />} />
|
||||
<Route path="/statistics/apis" element={<ApiStatsPage />} />
|
||||
<Route path="/statistics/tenants" element={<TenantStatsPage />} />
|
||||
<Route path="/statistics/usage-trend" element={<UsageTrendPage />} />
|
||||
<Route path="/apikeys/my-keys" element={<MyKeysPage />} />
|
||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||
<Route path="/apikeys/admin" element={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></RoleGuard>} />
|
||||
<Route path="/admin/services" element={<RoleGuard allowedRoles={['ADMIN']}><ServicesPage /></RoleGuard>} />
|
||||
<Route path="/admin/users" element={<RoleGuard allowedRoles={['ADMIN']}><UsersPage /></RoleGuard>} />
|
||||
<Route path="/admin/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
@ -1,22 +1,5 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const ProtectedRoute = () => {
|
||||
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 />;
|
||||
};
|
||||
const ProtectedRoute = () => <Outlet />;
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
||||
41
frontend/src/components/RoleGuard.tsx
Normal file
41
frontend/src/components/RoleGuard.tsx
Normal file
@ -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 { user, logout } = useAuth();
|
||||
const { user, setRole } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
Monitoring: true,
|
||||
@ -60,10 +62,6 @@ const MainLayout = () => {
|
||||
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
|
||||
return (
|
||||
@ -170,16 +168,22 @@ const MainLayout = () => {
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{user?.userName}</span>
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
{user?.role}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Role:</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||
{ROLES.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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 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 formatDate = (d: Date): string => {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getToday = (): string => formatDate(new Date());
|
||||
const getTodayString = getToday;
|
||||
|
||||
const formatDateTime = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
const year = d.getFullYear();
|
||||
@ -50,10 +49,10 @@ const RequestLogsPage = () => {
|
||||
|
||||
const [startDate, setStartDate] = useState(getTodayString());
|
||||
const [endDate, setEndDate] = useState(getTodayString());
|
||||
const [datePreset, setDatePreset] = useState('오늘');
|
||||
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);
|
||||
@ -84,7 +83,6 @@ const RequestLogsPage = () => {
|
||||
serviceId: serviceId ? Number(serviceId) : undefined,
|
||||
requestStatus: requestStatus || undefined,
|
||||
requestMethod: requestMethod || undefined,
|
||||
requestIp: requestIp || undefined,
|
||||
page,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
@ -104,10 +102,10 @@ const RequestLogsPage = () => {
|
||||
const handleReset = () => {
|
||||
setStartDate(getTodayString());
|
||||
setEndDate(getTodayString());
|
||||
setDatePreset('오늘');
|
||||
setServiceId('');
|
||||
setRequestStatus('');
|
||||
setRequestMethod('');
|
||||
setRequestIp('');
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
@ -169,45 +167,68 @@ const RequestLogsPage = () => {
|
||||
{/* Search Form */}
|
||||
<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>
|
||||
<div className="md:col-span-3">
|
||||
<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">
|
||||
<input
|
||||
type="date"
|
||||
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"
|
||||
/>
|
||||
<span className="text-gray-500 dark:text-gray-400">~</span>
|
||||
<input
|
||||
type="date"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<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
|
||||
value={serviceId}
|
||||
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>
|
||||
{services.map((s) => (
|
||||
<option key={s.serviceId} value={s.serviceId}>
|
||||
{s.serviceName}
|
||||
</option>
|
||||
<option key={s.serviceId} value={s.serviceId}>{s.serviceName}</option>
|
||||
))}
|
||||
</select>
|
||||
</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
|
||||
value={requestStatus}
|
||||
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>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
@ -215,14 +236,12 @@ const RequestLogsPage = () => {
|
||||
))}
|
||||
</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 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
|
||||
value={requestMethod}
|
||||
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>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
@ -230,17 +249,7 @@ const RequestLogsPage = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<div className="flex items-end gap-2 ml-auto">
|
||||
<button
|
||||
onClick={() => handleSearch(0)}
|
||||
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';
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
let currentUserId: number = 1;
|
||||
|
||||
const getAccessToken = (): string | null => {
|
||||
return localStorage.getItem('snp_access_token');
|
||||
};
|
||||
|
||||
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;
|
||||
export const setApiClientUserId = (userId: number) => {
|
||||
currentUserId = userId;
|
||||
};
|
||||
|
||||
const request = async <T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> => {
|
||||
const token = getAccessToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': String(currentUserId),
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response = await fetch(`${BASE_URL}${url}`, {
|
||||
const response = await fetch(`${BASE_URL}${url}`, {
|
||||
...options,
|
||||
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>;
|
||||
return data;
|
||||
};
|
||||
|
||||
@ -1,67 +1,2 @@
|
||||
import { post } from './apiClient';
|
||||
import type { LoginRequest, LoginResponse, User } from '../types/auth';
|
||||
|
||||
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);
|
||||
};
|
||||
// Auth service removed - login/logout no longer needed.
|
||||
// Role is managed via AuthContext's setRole function.
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
import { createContext, useState, useEffect, useCallback } from 'react';
|
||||
import { createContext, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { User } from '../types/auth';
|
||||
import * as authService from '../services/authService';
|
||||
import { setApiClientUserId } from '../services/apiClient';
|
||||
|
||||
interface AuthState {
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
login: (loginId: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
setRole: (role: 'ADMIN' | 'MANAGER' | 'USER') => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@ -20,56 +16,32 @@ interface AuthProviderProps {
|
||||
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 [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
});
|
||||
const [user, setUser] = useState<User>(getInitialUser);
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = authService.getStoredUser();
|
||||
const token = authService.getAccessToken();
|
||||
|
||||
if (storedUser && token) {
|
||||
setState({
|
||||
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,
|
||||
});
|
||||
}, []);
|
||||
const setRole = (role: 'ADMIN' | 'MANAGER' | 'USER') => {
|
||||
localStorage.setItem('snp-role', role);
|
||||
const u = ROLE_USERS[role];
|
||||
setUser(u);
|
||||
setApiClientUserId(u.userId);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...state, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, isAuthenticated: true, isLoading: false, setRole }}>
|
||||
{children}
|
||||
</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 {
|
||||
userId: number;
|
||||
loginId: string;
|
||||
userName: 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.ApiKeyService;
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@ -34,6 +34,7 @@ public class ApiKeyController {
|
||||
|
||||
private final ApiKeyService apiKeyService;
|
||||
private final ApiKeyPermissionService apiKeyPermissionService;
|
||||
private final HttpServletRequest request;
|
||||
|
||||
/**
|
||||
* 내 API Key 목록 조회
|
||||
@ -49,7 +50,6 @@ public class ApiKeyController {
|
||||
* 전체 API Key 목록 조회 (관리자용)
|
||||
*/
|
||||
@GetMapping("/all")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<List<ApiKeyResponse>>> getAllKeys() {
|
||||
List<ApiKeyResponse> keys = apiKeyService.getAllKeys();
|
||||
return ResponseEntity.ok(ApiResponse.ok(keys));
|
||||
@ -59,7 +59,6 @@ public class ApiKeyController {
|
||||
* API Key 상세 조회 (복호화된 키 포함, 관리자 전용)
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<ApiKeyDetailResponse>> getKeyDetail(@PathVariable Long id) {
|
||||
ApiKeyDetailResponse detail = apiKeyService.getKeyDetail(id);
|
||||
return ResponseEntity.ok(ApiResponse.ok(detail));
|
||||
@ -69,7 +68,6 @@ public class ApiKeyController {
|
||||
* API Key 생성 (관리자 전용)
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> createKey(
|
||||
@RequestBody @Valid CreateApiKeyRequest request) {
|
||||
Long userId = getCurrentUserId();
|
||||
@ -99,7 +97,6 @@ public class ApiKeyController {
|
||||
* API Key 권한 수정 (관리자/매니저 전용)
|
||||
*/
|
||||
@PutMapping("/{id}/permissions")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<List<PermissionResponse>>> updatePermissions(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Valid UpdatePermissionsRequest request) {
|
||||
@ -109,6 +106,15 @@ public class ApiKeyController {
|
||||
}
|
||||
|
||||
private Long getCurrentUserId() {
|
||||
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
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());
|
||||
} 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.service.ApiKeyRequestService;
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@ -31,6 +31,7 @@ import java.util.List;
|
||||
public class ApiKeyRequestController {
|
||||
|
||||
private final ApiKeyRequestService apiKeyRequestService;
|
||||
private final HttpServletRequest request;
|
||||
|
||||
/**
|
||||
* API Key 신청 생성
|
||||
@ -57,7 +58,6 @@ public class ApiKeyRequestController {
|
||||
* 신청 목록 조회 (관리자/매니저용)
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<List<ApiKeyRequestResponse>>> getRequests(
|
||||
@RequestParam(required = false) String status) {
|
||||
List<ApiKeyRequestResponse> responses;
|
||||
@ -73,7 +73,6 @@ public class ApiKeyRequestController {
|
||||
* 신청 심사 (승인/거절)
|
||||
*/
|
||||
@PutMapping("/{id}/review")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> reviewRequest(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Valid ApiKeyRequestReviewDto request) {
|
||||
@ -86,6 +85,14 @@ public class ApiKeyRequestController {
|
||||
}
|
||||
|
||||
private Long getCurrentUserId() {
|
||||
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
String userIdHeader = request.getHeader("X-User-Id");
|
||||
if (userIdHeader != null && !userIdHeader.isBlank()) {
|
||||
return Long.parseLong(userIdHeader);
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
} catch (Exception e) {
|
||||
return 1L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
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.Configuration;
|
||||
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.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
// @EnableMethodSecurity -- disabled (no login)
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
@ -42,9 +36,8 @@ public class SecurityConfig {
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
|
||||
.requestMatchers("/gateway/**").permitAll()
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll())
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.requestMatchers("/api/**").permitAll()
|
||||
.anyRequest().permitAll());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@ -12,8 +12,14 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
@Controller
|
||||
public class WebViewController {
|
||||
|
||||
@GetMapping({"/", "/login", "/dashboard", "/dashboard/**",
|
||||
"/monitoring/**", "/apikeys", "/apikeys/**",
|
||||
@GetMapping("/")
|
||||
public String root() {
|
||||
return "redirect:/dashboard";
|
||||
}
|
||||
|
||||
@GetMapping({"/dashboard", "/dashboard/**",
|
||||
"/monitoring/**", "/statistics/**",
|
||||
"/apikeys", "/apikeys/**",
|
||||
"/admin/**"})
|
||||
public String forward() {
|
||||
return "forward:/index.html";
|
||||
|
||||
@ -7,7 +7,6 @@ import com.gcsc.connection.monitoring.dto.ServiceStatusDetailResponse;
|
||||
import com.gcsc.connection.monitoring.service.HeartbeatService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@ -68,7 +67,6 @@ public class HeartbeatController {
|
||||
* 수동 헬스체크 실행
|
||||
*/
|
||||
@PostMapping("/{serviceId}/check")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<HeartbeatStatusResponse>> checkService(
|
||||
@PathVariable Long serviceId) {
|
||||
HeartbeatStatusResponse result = heartbeatService.checkService(serviceId);
|
||||
|
||||
@ -2,8 +2,10 @@ package com.gcsc.connection.monitoring.entity;
|
||||
|
||||
import com.gcsc.connection.service.entity.SnpService;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ConstraintMode;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.ForeignKey;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
@ -28,7 +30,8 @@ public class SnpServiceHealthLog {
|
||||
private Long logId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "service_id", nullable = false)
|
||||
@JoinColumn(name = "service_id", nullable = false,
|
||||
foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
|
||||
private SnpService service;
|
||||
|
||||
@Column(name = "previous_status", length = 10)
|
||||
|
||||
@ -1,38 +1,23 @@
|
||||
package com.gcsc.connection.monitoring.scheduler;
|
||||
|
||||
import com.gcsc.connection.monitoring.repository.SnpServiceHealthLogRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DataCleanupScheduler {
|
||||
|
||||
private final SnpServiceHealthLogRepository healthLogRepository;
|
||||
|
||||
@Value("${app.retention.health-log-days:90}")
|
||||
private int healthLogRetentionDays;
|
||||
|
||||
/**
|
||||
* 매일 02:00 실행 - 오래된 데이터 정리
|
||||
* Health log는 PartitionManageScheduler에서 파티션 DROP으로 관리
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * *")
|
||||
@Transactional
|
||||
public void cleanupOldData() {
|
||||
log.info("데이터 정리 시작");
|
||||
|
||||
// Health log 정리
|
||||
LocalDateTime healthCutoff = LocalDateTime.now().minusDays(healthLogRetentionDays);
|
||||
int deletedHealthLogs = healthLogRepository.deleteOlderThan(healthCutoff);
|
||||
log.info("Health log 정리 완료: {}건 삭제 (기준: {}일 이전)", deletedHealthLogs, healthLogRetentionDays);
|
||||
|
||||
log.info("데이터 정리 완료");
|
||||
// Health log는 PartitionManageScheduler에서 파티션 DROP으로 관리
|
||||
log.info("데이터 정리 완료 (health_log는 파티션으로 관리)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
@ -27,6 +28,10 @@ public class PartitionManageScheduler {
|
||||
@Value("${app.retention.request-log-days:90}")
|
||||
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 실행 - 파티션 생성/삭제
|
||||
*/
|
||||
@ -64,6 +69,45 @@ public class PartitionManageScheduler {
|
||||
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) {
|
||||
try {
|
||||
String suffix = partitionName.replace("snp_api_request_log_", "");
|
||||
|
||||
@ -57,11 +57,53 @@ public class PartitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 파티션 목록 조회
|
||||
* 기존 파티션 목록 조회 (API 요청 로그용 - 하위 호환)
|
||||
*/
|
||||
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 '"
|
||||
+ PARTITION_PREFIX + "%' ORDER BY tablename";
|
||||
+ tablePrefix + "%' ORDER BY tablename";
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@ -44,7 +43,6 @@ public class ServiceController {
|
||||
* 서비스 생성
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<ServiceResponse>> createService(
|
||||
@RequestBody @Valid CreateServiceRequest request) {
|
||||
ServiceResponse service = serviceManagementService.createService(request);
|
||||
@ -55,7 +53,6 @@ public class ServiceController {
|
||||
* 서비스 수정
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<ServiceResponse>> updateService(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Valid UpdateServiceRequest request) {
|
||||
@ -77,7 +74,6 @@ public class ServiceController {
|
||||
* 서비스 API 생성
|
||||
*/
|
||||
@PostMapping("/{id}/apis")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<ServiceApiResponse>> createServiceApi(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Valid CreateServiceApiRequest request) {
|
||||
|
||||
@ -8,7 +8,6 @@ import com.gcsc.connection.tenant.service.TenantService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@ -33,7 +32,6 @@ public class TenantController {
|
||||
* 전체 테넌트 목록 조회
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<List<TenantResponse>>> getTenants() {
|
||||
List<TenantResponse> tenants = tenantService.getTenants();
|
||||
return ResponseEntity.ok(ApiResponse.ok(tenants));
|
||||
@ -43,7 +41,6 @@ public class TenantController {
|
||||
* 테넌트 생성
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<TenantResponse>> createTenant(
|
||||
@RequestBody @Valid CreateTenantRequest request) {
|
||||
TenantResponse tenant = tenantService.createTenant(request);
|
||||
@ -54,7 +51,6 @@ public class TenantController {
|
||||
* 테넌트 수정
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<TenantResponse>> updateTenant(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Valid UpdateTenantRequest request) {
|
||||
|
||||
@ -9,7 +9,6 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -36,7 +35,6 @@ public class UserController {
|
||||
* 전체 사용자 목록 조회
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<List<UserResponse>>> getUsers() {
|
||||
List<UserResponse> users = userService.getUsers();
|
||||
return ResponseEntity.ok(ApiResponse.ok(users));
|
||||
@ -46,7 +44,6 @@ public class UserController {
|
||||
* 사용자 생성
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> createUser(
|
||||
@RequestBody @Valid CreateUserRequest request) {
|
||||
UserResponse user = userService.createUser(request);
|
||||
@ -57,7 +54,6 @@ public class UserController {
|
||||
* 사용자 단건 조회 (ADMIN/MANAGER 또는 본인)
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long id) {
|
||||
String currentUserId = SecurityContextHolder.getContext().getAuthentication().getName();
|
||||
if (!isAdminOrManager() && !currentUserId.equals(String.valueOf(id))) {
|
||||
@ -86,7 +82,6 @@ public class UserController {
|
||||
* 사용자 비활성화 (소프트 삭제)
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<Void>> deactivateUser(@PathVariable Long id) {
|
||||
userService.deactivateUser(id);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "사용자가 비활성화되었습니다"));
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user