diff --git a/CLAUDE.md b/CLAUDE.md
index 4c0fedf..200cbb5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ API Gateway + 모니터링 통합 플랫폼. 모든 서비스 사용자가 모
## 기술 스택
- Java 17, Spring Boot 3.2.1, Spring Data JPA
-- PostgreSQL (스키마: std_snp_connection)
+- PostgreSQL (DB: snp_connection, 스키마: common)
- Spring Security (JWT 기반 인증 예정)
- WebFlux WebClient (Heartbeat, Gateway Proxy)
- Springdoc OpenAPI 2.3.0 (Swagger)
diff --git a/docs/schema/create_tables.sql b/docs/schema/create_tables.sql
new file mode 100644
index 0000000..6369579
--- /dev/null
+++ b/docs/schema/create_tables.sql
@@ -0,0 +1,187 @@
+-- =============================================================
+-- SNP Connection Monitoring - 테이블 생성 스크립트
+-- 스키마: common
+-- =============================================================
+
+CREATE SCHEMA IF NOT EXISTS common;
+SET search_path TO common;
+
+-- -----------------------------------------------------------
+-- 1. snp_tenant (테넌트)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_tenant (
+ tenant_id BIGSERIAL PRIMARY KEY,
+ tenant_code VARCHAR(50) NOT NULL UNIQUE,
+ tenant_name VARCHAR(200) NOT NULL,
+ description TEXT,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_snp_tenant_code ON snp_tenant (tenant_code);
+CREATE INDEX idx_snp_tenant_active ON snp_tenant (is_active);
+
+-- -----------------------------------------------------------
+-- 2. snp_user (사용자)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_user (
+ user_id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT REFERENCES snp_tenant(tenant_id),
+ login_id VARCHAR(100) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ user_name VARCHAR(100) NOT NULL,
+ email VARCHAR(200),
+ role VARCHAR(20) NOT NULL DEFAULT 'USER',
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ last_login_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_snp_user_login_id ON snp_user (login_id);
+CREATE INDEX idx_snp_user_tenant ON snp_user (tenant_id);
+CREATE INDEX idx_snp_user_role ON snp_user (role);
+
+-- -----------------------------------------------------------
+-- 3. snp_service (서비스)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_service (
+ service_id BIGSERIAL PRIMARY KEY,
+ service_code VARCHAR(50) NOT NULL UNIQUE,
+ service_name VARCHAR(200) NOT NULL,
+ service_url VARCHAR(500),
+ description TEXT,
+ health_check_url VARCHAR(500),
+ health_check_interval INTEGER NOT NULL DEFAULT 30,
+ health_status VARCHAR(10) NOT NULL DEFAULT 'UNKNOWN',
+ health_checked_at TIMESTAMP,
+ health_response_time INTEGER,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_snp_service_code ON snp_service (service_code);
+CREATE INDEX idx_snp_service_status ON snp_service (health_status);
+CREATE INDEX idx_snp_service_active ON snp_service (is_active);
+
+-- -----------------------------------------------------------
+-- 4. snp_service_health_log (서비스 헬스 로그)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_service_health_log (
+ log_id BIGSERIAL PRIMARY KEY,
+ service_id BIGINT NOT NULL REFERENCES snp_service(service_id),
+ previous_status VARCHAR(10),
+ current_status VARCHAR(10) NOT NULL,
+ response_time INTEGER,
+ error_message TEXT,
+ checked_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+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);
+
+-- -----------------------------------------------------------
+-- 5. snp_service_api (서비스 API)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_service_api (
+ api_id BIGSERIAL PRIMARY KEY,
+ service_id BIGINT NOT NULL REFERENCES snp_service(service_id),
+ api_path VARCHAR(500) NOT NULL,
+ api_method VARCHAR(10) NOT NULL,
+ api_name VARCHAR(200) NOT NULL,
+ description TEXT,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ CONSTRAINT uq_service_api UNIQUE (service_id, api_path, api_method)
+);
+
+CREATE INDEX idx_snp_service_api_service ON snp_service_api (service_id);
+
+-- -----------------------------------------------------------
+-- 6. snp_api_key (API 키)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_api_key (
+ api_key_id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES snp_user(user_id),
+ api_key VARCHAR(128) NOT NULL UNIQUE,
+ api_key_prefix VARCHAR(10),
+ key_name VARCHAR(200) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+ approved_by BIGINT REFERENCES snp_user(user_id),
+ approved_at TIMESTAMP,
+ expires_at TIMESTAMP,
+ last_used_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_snp_api_key_user ON snp_api_key (user_id);
+CREATE INDEX idx_snp_api_key_key ON snp_api_key (api_key);
+CREATE INDEX idx_snp_api_key_status ON snp_api_key (status);
+
+-- -----------------------------------------------------------
+-- 7. snp_api_permission (API 권한)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_api_permission (
+ permission_id BIGSERIAL PRIMARY KEY,
+ api_key_id BIGINT NOT NULL REFERENCES snp_api_key(api_key_id),
+ api_id BIGINT NOT NULL REFERENCES snp_service_api(api_id),
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ granted_by BIGINT,
+ granted_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ revoked_at TIMESTAMP,
+ CONSTRAINT uq_api_permission UNIQUE (api_key_id, api_id)
+);
+
+CREATE INDEX idx_snp_api_permission_key ON snp_api_permission (api_key_id);
+CREATE INDEX idx_snp_api_permission_api ON snp_api_permission (api_id);
+
+-- -----------------------------------------------------------
+-- 8. snp_api_request_log (API 요청 로그)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_api_request_log (
+ log_id BIGSERIAL PRIMARY KEY,
+ request_url VARCHAR(2000),
+ request_params TEXT,
+ request_method VARCHAR(10),
+ request_status VARCHAR(20),
+ request_headers TEXT,
+ request_ip VARCHAR(45),
+ service_id BIGINT REFERENCES snp_service(service_id),
+ user_id BIGINT REFERENCES snp_user(user_id),
+ api_key_id BIGINT REFERENCES snp_api_key(api_key_id),
+ response_size BIGINT,
+ response_time INTEGER,
+ response_status INTEGER,
+ error_message TEXT,
+ requested_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ tenant_id BIGINT REFERENCES snp_tenant(tenant_id)
+);
+
+CREATE INDEX idx_snp_request_log_service ON snp_api_request_log (service_id);
+CREATE INDEX idx_snp_request_log_user ON snp_api_request_log (user_id);
+CREATE INDEX idx_snp_request_log_requested ON snp_api_request_log (requested_at);
+CREATE INDEX idx_snp_request_log_tenant ON snp_api_request_log (tenant_id);
+
+-- -----------------------------------------------------------
+-- 9. snp_api_key_request (API 키 발급 요청)
+-- -----------------------------------------------------------
+CREATE TABLE IF NOT EXISTS snp_api_key_request (
+ request_id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES snp_user(user_id),
+ key_name VARCHAR(200) NOT NULL,
+ purpose TEXT,
+ requested_apis TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+ reviewed_by BIGINT REFERENCES snp_user(user_id),
+ reviewed_at TIMESTAMP,
+ review_comment TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_snp_key_request_user ON snp_api_key_request (user_id);
+CREATE INDEX idx_snp_key_request_status ON snp_api_key_request (status);
diff --git a/docs/schema/initial_data.sql b/docs/schema/initial_data.sql
new file mode 100644
index 0000000..8c1d137
--- /dev/null
+++ b/docs/schema/initial_data.sql
@@ -0,0 +1,24 @@
+-- =============================================================
+-- SNP Connection Monitoring - 초기 데이터
+-- 스키마: common
+-- =============================================================
+
+SET search_path TO common;
+
+-- 기본 테넌트
+INSERT INTO snp_tenant (tenant_code, tenant_name, description, is_active)
+VALUES ('DEFAULT', '기본 테넌트', 'SNP Connection Monitoring 기본 테넌트', TRUE)
+ON CONFLICT (tenant_code) DO NOTHING;
+
+-- 관리자 계정 (password: admin123, BCrypt 해시)
+INSERT INTO snp_user (tenant_id, login_id, password_hash, user_name, email, role, is_active)
+VALUES (
+ (SELECT tenant_id FROM snp_tenant WHERE tenant_code = 'DEFAULT'),
+ 'admin',
+ '$2b$10$res6.RkRwakcEui0XbCpOOxYzwQiT07/J0Jl4cKlMtaDZFRyDt1EC',
+ '시스템 관리자',
+ 'admin@gcsc.com',
+ 'ADMIN',
+ TRUE
+)
+ON CONFLICT (login_id) DO NOTHING;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ce1211a..e985552 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,26 +1,48 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+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 MyKeysPage from './pages/apikeys/MyKeysPage';
+import KeyRequestPage from './pages/apikeys/KeyRequestPage';
+import KeyAdminPage from './pages/apikeys/KeyAdminPage';
+import ServicesPage from './pages/admin/ServicesPage';
+import UsersPage from './pages/admin/UsersPage';
+import TenantsPage from './pages/admin/TenantsPage';
+import NotFoundPage from './pages/NotFoundPage';
const BASE_PATH = '/snp-connection';
-function App() {
+const App = () => {
return (
-
- } />
-
-
-
SNP Connection Monitoring
-
Dashboard - Coming Soon
-
-
- }
- />
-
+
+
+ }>
+ } />
+
+
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
);
-}
+};
export default App;
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..6a3d913
--- /dev/null
+++ b/frontend/src/components/ProtectedRoute.tsx
@@ -0,0 +1,22 @@
+import { Navigate, Outlet } from 'react-router-dom';
+import { useAuth } from '../hooks/useAuth';
+
+const ProtectedRoute = () => {
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return ;
+};
+
+export default ProtectedRoute;
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
new file mode 100644
index 0000000..68173a3
--- /dev/null
+++ b/frontend/src/hooks/useAuth.ts
@@ -0,0 +1,8 @@
+import { useContext } from 'react';
+import { AuthContext } from '../store/AuthContext';
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) throw new Error('useAuth must be used within AuthProvider');
+ return context;
+};
diff --git a/frontend/src/layouts/AuthLayout.tsx b/frontend/src/layouts/AuthLayout.tsx
new file mode 100644
index 0000000..6819e6a
--- /dev/null
+++ b/frontend/src/layouts/AuthLayout.tsx
@@ -0,0 +1,11 @@
+import { Outlet } from 'react-router-dom';
+
+const AuthLayout = () => {
+ return (
+
+
+
+ );
+};
+
+export default AuthLayout;
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
new file mode 100644
index 0000000..cd8d38c
--- /dev/null
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -0,0 +1,166 @@
+import { useState } from 'react';
+import { Outlet, NavLink } from 'react-router-dom';
+import { useAuth } from '../hooks/useAuth';
+
+interface NavGroup {
+ label: string;
+ items: { label: string; path: string }[];
+ adminOnly?: boolean;
+}
+
+const navGroups: NavGroup[] = [
+ {
+ label: 'Monitoring',
+ items: [
+ { label: 'Request Logs', path: '/monitoring/request-logs' },
+ ],
+ },
+ {
+ label: 'API Keys',
+ items: [
+ { label: 'My Keys', path: '/apikeys/my-keys' },
+ { label: 'Request', path: '/apikeys/request' },
+ { label: 'Admin', path: '/apikeys/admin' },
+ ],
+ },
+ {
+ label: 'Admin',
+ adminOnly: true,
+ items: [
+ { label: 'Services', path: '/admin/services' },
+ { label: 'Users', path: '/admin/users' },
+ { label: 'Tenants', path: '/admin/tenants' },
+ ],
+ },
+];
+
+const MainLayout = () => {
+ const { user, logout } = useAuth();
+ const [openGroups, setOpenGroups] = useState>({
+ Monitoring: true,
+ 'API Keys': true,
+ Admin: true,
+ });
+
+ const toggleGroup = (label: string) => {
+ setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
+ };
+
+ const handleLogout = async () => {
+ await logout();
+ };
+
+ const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
+
+ return (
+
+ {/* Sidebar */}
+
+
+ {/* Main Content */}
+
+ {/* Header */}
+
+
+ {/* Content */}
+
+
+
+
+
+ );
+};
+
+export default MainLayout;
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..dd0a71b
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -0,0 +1,10 @@
+const DashboardPage = () => {
+ return (
+
+
Dashboard
+
Coming soon
+
+ );
+};
+
+export default DashboardPage;
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..0a0a44c
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,93 @@
+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 (
+
+
+
+ SNP Connection Monitoring
+
+
+
+
+
+ );
+};
+
+export default LoginPage;
diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..5dc4188
--- /dev/null
+++ b/frontend/src/pages/NotFoundPage.tsx
@@ -0,0 +1,18 @@
+import { Link } from 'react-router-dom';
+
+const NotFoundPage = () => {
+ return (
+
+
404 - Page Not Found
+
요청하신 페이지를 찾을 수 없습니다.
+
+ Dashboard로 이동
+
+
+ );
+};
+
+export default NotFoundPage;
diff --git a/frontend/src/pages/admin/ServicesPage.tsx b/frontend/src/pages/admin/ServicesPage.tsx
new file mode 100644
index 0000000..49de803
--- /dev/null
+++ b/frontend/src/pages/admin/ServicesPage.tsx
@@ -0,0 +1,10 @@
+const ServicesPage = () => {
+ return (
+
+
Services
+
Coming soon
+
+ );
+};
+
+export default ServicesPage;
diff --git a/frontend/src/pages/admin/TenantsPage.tsx b/frontend/src/pages/admin/TenantsPage.tsx
new file mode 100644
index 0000000..58cab6f
--- /dev/null
+++ b/frontend/src/pages/admin/TenantsPage.tsx
@@ -0,0 +1,10 @@
+const TenantsPage = () => {
+ return (
+
+
Tenants
+
Coming soon
+
+ );
+};
+
+export default TenantsPage;
diff --git a/frontend/src/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx
new file mode 100644
index 0000000..49e8c1f
--- /dev/null
+++ b/frontend/src/pages/admin/UsersPage.tsx
@@ -0,0 +1,10 @@
+const UsersPage = () => {
+ return (
+
+ );
+};
+
+export default UsersPage;
diff --git a/frontend/src/pages/apikeys/KeyAdminPage.tsx b/frontend/src/pages/apikeys/KeyAdminPage.tsx
new file mode 100644
index 0000000..21f557f
--- /dev/null
+++ b/frontend/src/pages/apikeys/KeyAdminPage.tsx
@@ -0,0 +1,10 @@
+const KeyAdminPage = () => {
+ return (
+
+
API Key Admin
+
Coming soon
+
+ );
+};
+
+export default KeyAdminPage;
diff --git a/frontend/src/pages/apikeys/KeyRequestPage.tsx b/frontend/src/pages/apikeys/KeyRequestPage.tsx
new file mode 100644
index 0000000..52b8bcd
--- /dev/null
+++ b/frontend/src/pages/apikeys/KeyRequestPage.tsx
@@ -0,0 +1,10 @@
+const KeyRequestPage = () => {
+ return (
+
+
API Key Request
+
Coming soon
+
+ );
+};
+
+export default KeyRequestPage;
diff --git a/frontend/src/pages/apikeys/MyKeysPage.tsx b/frontend/src/pages/apikeys/MyKeysPage.tsx
new file mode 100644
index 0000000..4bc8538
--- /dev/null
+++ b/frontend/src/pages/apikeys/MyKeysPage.tsx
@@ -0,0 +1,10 @@
+const MyKeysPage = () => {
+ return (
+
+
My API Keys
+
Coming soon
+
+ );
+};
+
+export default MyKeysPage;
diff --git a/frontend/src/pages/monitoring/RequestLogsPage.tsx b/frontend/src/pages/monitoring/RequestLogsPage.tsx
new file mode 100644
index 0000000..ac6fc43
--- /dev/null
+++ b/frontend/src/pages/monitoring/RequestLogsPage.tsx
@@ -0,0 +1,10 @@
+const RequestLogsPage = () => {
+ return (
+
+
Request Logs
+
Coming soon
+
+ );
+};
+
+export default RequestLogsPage;
diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts
new file mode 100644
index 0000000..85a3462
--- /dev/null
+++ b/frontend/src/services/apiClient.ts
@@ -0,0 +1,116 @@
+import type { ApiResponse } from '../types/api';
+
+const BASE_URL = '/snp-connection/api';
+
+let isRefreshing = false;
+let refreshPromise: Promise | null = null;
+
+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 => {
+ 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 => {
+ if (isRefreshing && refreshPromise) {
+ return refreshPromise;
+ }
+
+ isRefreshing = true;
+ refreshPromise = attemptRefresh().finally(() => {
+ isRefreshing = false;
+ refreshPromise = null;
+ });
+
+ return refreshPromise;
+};
+
+const request = async (url: string, options: RequestInit = {}): Promise> => {
+ const token = getAccessToken();
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...options.headers as Record,
+ };
+
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ let 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;
+ return data;
+};
+
+export const get = (url: string): Promise> => {
+ return request(url, { method: 'GET' });
+};
+
+export const post = (url: string, body?: unknown): Promise> => {
+ return request(url, {
+ method: 'POST',
+ body: body ? JSON.stringify(body) : undefined,
+ });
+};
+
+export const put = (url: string, body?: unknown): Promise> => {
+ return request(url, {
+ method: 'PUT',
+ body: body ? JSON.stringify(body) : undefined,
+ });
+};
+
+export const del = (url: string): Promise> => {
+ return request(url, { method: 'DELETE' });
+};
diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts
new file mode 100644
index 0000000..4e453ad
--- /dev/null
+++ b/frontend/src/services/authService.ts
@@ -0,0 +1,67 @@
+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 => {
+ const response = await post('/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 => {
+ try {
+ await post('/auth/logout');
+ } finally {
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
+ localStorage.removeItem(USER_KEY);
+ }
+};
+
+export const refreshToken = async (): Promise => {
+ 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);
+};
diff --git a/frontend/src/store/AuthContext.tsx b/frontend/src/store/AuthContext.tsx
new file mode 100644
index 0000000..a6d8ff4
--- /dev/null
+++ b/frontend/src/store/AuthContext.tsx
@@ -0,0 +1,78 @@
+import { createContext, useState, useEffect, useCallback } from 'react';
+import type { ReactNode } from 'react';
+import type { User } from '../types/auth';
+import * as authService from '../services/authService';
+
+interface AuthState {
+ user: User | null;
+ isAuthenticated: boolean;
+ isLoading: boolean;
+}
+
+interface AuthContextValue extends AuthState {
+ login: (loginId: string, password: string) => Promise;
+ logout: () => Promise;
+}
+
+export const AuthContext = createContext(null);
+
+interface AuthProviderProps {
+ children: ReactNode;
+}
+
+const AuthProvider = ({ children }: AuthProviderProps) => {
+ const [state, setState] = useState({
+ user: null,
+ isAuthenticated: false,
+ isLoading: true,
+ });
+
+ 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,
+ });
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default AuthProvider;
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
new file mode 100644
index 0000000..a12cbed
--- /dev/null
+++ b/frontend/src/types/api.ts
@@ -0,0 +1,5 @@
+export interface ApiResponse {
+ success: boolean;
+ message?: string;
+ data?: T;
+}
diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts
new file mode 100644
index 0000000..009136b
--- /dev/null
+++ b/frontend/src/types/auth.ts
@@ -0,0 +1,19 @@
+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 {
+ loginId: string;
+ userName: string;
+ role: string;
+}
diff --git a/pom.xml b/pom.xml
index c9d2cc6..82c2ed2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -104,6 +104,25 @@
2.3.0
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+
org.springframework.boot
diff --git a/src/main/java/com/gcsc/connection/apikey/entity/ApiKeyStatus.java b/src/main/java/com/gcsc/connection/apikey/entity/ApiKeyStatus.java
new file mode 100644
index 0000000..3f52999
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/entity/ApiKeyStatus.java
@@ -0,0 +1,9 @@
+package com.gcsc.connection.apikey.entity;
+
+public enum ApiKeyStatus {
+ PENDING,
+ ACTIVE,
+ INACTIVE,
+ EXPIRED,
+ REVOKED
+}
diff --git a/src/main/java/com/gcsc/connection/apikey/entity/KeyRequestStatus.java b/src/main/java/com/gcsc/connection/apikey/entity/KeyRequestStatus.java
new file mode 100644
index 0000000..a7aaa3b
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/entity/KeyRequestStatus.java
@@ -0,0 +1,7 @@
+package com.gcsc.connection.apikey.entity;
+
+public enum KeyRequestStatus {
+ PENDING,
+ APPROVED,
+ REJECTED
+}
diff --git a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java
new file mode 100644
index 0000000..b69e000
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKey.java
@@ -0,0 +1,76 @@
+package com.gcsc.connection.apikey.entity;
+
+import com.gcsc.connection.common.entity.BaseEntity;
+import com.gcsc.connection.user.entity.SnpUser;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_api_key", schema = "common")
+public class SnpApiKey extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "api_key_id")
+ private Long apiKeyId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private SnpUser user;
+
+ @Column(name = "api_key", length = 128, nullable = false, unique = true)
+ private String apiKey;
+
+ @Column(name = "api_key_prefix", length = 10)
+ private String apiKeyPrefix;
+
+ @Column(name = "key_name", length = 200, nullable = false)
+ private String keyName;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", length = 20, nullable = false)
+ private ApiKeyStatus status = ApiKeyStatus.PENDING;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "approved_by")
+ private SnpUser approvedBy;
+
+ @Column(name = "approved_at")
+ private LocalDateTime approvedAt;
+
+ @Column(name = "expires_at")
+ private LocalDateTime expiresAt;
+
+ @Column(name = "last_used_at")
+ private LocalDateTime lastUsedAt;
+
+ @Builder
+ public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
+ ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
+ LocalDateTime expiresAt) {
+ this.user = user;
+ this.apiKey = apiKey;
+ this.apiKeyPrefix = apiKeyPrefix;
+ this.keyName = keyName;
+ this.status = status != null ? status : ApiKeyStatus.PENDING;
+ this.approvedBy = approvedBy;
+ this.approvedAt = approvedAt;
+ this.expiresAt = expiresAt;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKeyRequest.java b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKeyRequest.java
new file mode 100644
index 0000000..30eac98
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiKeyRequest.java
@@ -0,0 +1,69 @@
+package com.gcsc.connection.apikey.entity;
+
+import com.gcsc.connection.common.entity.BaseEntity;
+import com.gcsc.connection.user.entity.SnpUser;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_api_key_request", schema = "common")
+public class SnpApiKeyRequest extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "request_id")
+ private Long requestId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private SnpUser user;
+
+ @Column(name = "key_name", length = 200, nullable = false)
+ private String keyName;
+
+ @Column(name = "purpose", columnDefinition = "TEXT")
+ private String purpose;
+
+ @Column(name = "requested_apis", columnDefinition = "TEXT")
+ private String requestedApis;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", length = 20, nullable = false)
+ private KeyRequestStatus status = KeyRequestStatus.PENDING;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "reviewed_by")
+ private SnpUser reviewedBy;
+
+ @Column(name = "reviewed_at")
+ private LocalDateTime reviewedAt;
+
+ @Column(name = "review_comment", columnDefinition = "TEXT")
+ private String reviewComment;
+
+ @Builder
+ public SnpApiKeyRequest(SnpUser user, String keyName, String purpose, String requestedApis,
+ KeyRequestStatus status) {
+ this.user = user;
+ this.keyName = keyName;
+ this.purpose = purpose;
+ this.requestedApis = requestedApis;
+ this.status = status != null ? status : KeyRequestStatus.PENDING;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/apikey/entity/SnpApiPermission.java b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiPermission.java
new file mode 100644
index 0000000..da04e7a
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/entity/SnpApiPermission.java
@@ -0,0 +1,61 @@
+package com.gcsc.connection.apikey.entity;
+
+import com.gcsc.connection.service.entity.SnpServiceApi;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_api_permission", schema = "common",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"api_key_id", "api_id"}))
+public class SnpApiPermission {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "permission_id")
+ private Long permissionId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "api_key_id", nullable = false)
+ private SnpApiKey apiKey;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "api_id", nullable = false)
+ private SnpServiceApi api;
+
+ @Column(name = "is_active", nullable = false)
+ private Boolean isActive = true;
+
+ @Column(name = "granted_by")
+ private Long grantedBy;
+
+ @Column(name = "granted_at", nullable = false)
+ private LocalDateTime grantedAt;
+
+ @Column(name = "revoked_at")
+ private LocalDateTime revokedAt;
+
+ @Builder
+ public SnpApiPermission(SnpApiKey apiKey, SnpServiceApi api, Boolean isActive,
+ Long grantedBy, LocalDateTime grantedAt) {
+ this.apiKey = apiKey;
+ this.api = api;
+ this.isActive = isActive != null ? isActive : true;
+ this.grantedBy = grantedBy;
+ this.grantedAt = grantedAt != null ? grantedAt : LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java
new file mode 100644
index 0000000..cb6e5dd
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java
@@ -0,0 +1,11 @@
+package com.gcsc.connection.apikey.repository;
+
+import com.gcsc.connection.apikey.entity.SnpApiKey;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface SnpApiKeyRepository extends JpaRepository {
+
+ Optional findByApiKey(String apiKey);
+}
diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRequestRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRequestRepository.java
new file mode 100644
index 0000000..65f9777
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRequestRepository.java
@@ -0,0 +1,7 @@
+package com.gcsc.connection.apikey.repository;
+
+import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface SnpApiKeyRequestRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java
new file mode 100644
index 0000000..338cf43
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiPermissionRepository.java
@@ -0,0 +1,7 @@
+package com.gcsc.connection.apikey.repository;
+
+import com.gcsc.connection.apikey.entity.SnpApiPermission;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface SnpApiPermissionRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/gcsc/connection/auth/controller/AuthController.java b/src/main/java/com/gcsc/connection/auth/controller/AuthController.java
new file mode 100644
index 0000000..e143f5d
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/controller/AuthController.java
@@ -0,0 +1,54 @@
+package com.gcsc.connection.auth.controller;
+
+import com.gcsc.connection.auth.dto.LoginRequest;
+import com.gcsc.connection.auth.dto.LoginResponse;
+import com.gcsc.connection.auth.dto.TokenRefreshRequest;
+import com.gcsc.connection.auth.dto.TokenRefreshResponse;
+import com.gcsc.connection.auth.service.AuthService;
+import com.gcsc.connection.common.dto.ApiResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 인증 관련 API (로그인, 로그아웃, 토큰 갱신)
+ */
+@RestController
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private final AuthService authService;
+
+ /**
+ * 로그인 - JWT 토큰 발급
+ */
+ @PostMapping("/login")
+ public ResponseEntity> login(
+ @RequestBody @Valid LoginRequest request) {
+ LoginResponse response = authService.login(request);
+ return ResponseEntity.ok(ApiResponse.ok(response));
+ }
+
+ /**
+ * 로그아웃
+ */
+ @PostMapping("/logout")
+ public ResponseEntity> logout() {
+ return ResponseEntity.ok(ApiResponse.ok(null, "로그아웃되었습니다"));
+ }
+
+ /**
+ * 토큰 갱신 - Refresh Token으로 새 Access Token 발급
+ */
+ @PostMapping("/refresh")
+ public ResponseEntity> refresh(
+ @RequestBody @Valid TokenRefreshRequest request) {
+ TokenRefreshResponse response = authService.refresh(request);
+ return ResponseEntity.ok(ApiResponse.ok(response));
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/auth/dto/LoginRequest.java b/src/main/java/com/gcsc/connection/auth/dto/LoginRequest.java
new file mode 100644
index 0000000..d936ad2
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/dto/LoginRequest.java
@@ -0,0 +1,9 @@
+package com.gcsc.connection.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record LoginRequest(
+ @NotBlank String loginId,
+ @NotBlank String password
+) {
+}
diff --git a/src/main/java/com/gcsc/connection/auth/dto/LoginResponse.java b/src/main/java/com/gcsc/connection/auth/dto/LoginResponse.java
new file mode 100644
index 0000000..34ac606
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/dto/LoginResponse.java
@@ -0,0 +1,11 @@
+package com.gcsc.connection.auth.dto;
+
+public record LoginResponse(
+ String accessToken,
+ String refreshToken,
+ String loginId,
+ String userName,
+ String role,
+ long expiresIn
+) {
+}
diff --git a/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshRequest.java b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshRequest.java
new file mode 100644
index 0000000..af9798e
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshRequest.java
@@ -0,0 +1,8 @@
+package com.gcsc.connection.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record TokenRefreshRequest(
+ @NotBlank String refreshToken
+) {
+}
diff --git a/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshResponse.java b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshResponse.java
new file mode 100644
index 0000000..284a8c6
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/dto/TokenRefreshResponse.java
@@ -0,0 +1,7 @@
+package com.gcsc.connection.auth.dto;
+
+public record TokenRefreshResponse(
+ String accessToken,
+ long expiresIn
+) {
+}
diff --git a/src/main/java/com/gcsc/connection/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/gcsc/connection/auth/jwt/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..44f7bbb
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/jwt/JwtAuthenticationFilter.java
@@ -0,0 +1,67 @@
+package com.gcsc.connection.auth.jwt;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private static final String AUTHORIZATION_HEADER = "Authorization";
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ String token = resolveToken(request);
+
+ if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
+ Long userId = jwtTokenProvider.getUserId(token);
+ String loginId = jwtTokenProvider.getLoginId(token);
+ String role = jwtTokenProvider.parseClaims(token).get("role", String.class);
+
+ List authorities =
+ List.of(new SimpleGrantedAuthority("ROLE_" + role));
+
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(userId, loginId, authorities);
+
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+
+ filterChain.doFilter(request, response);
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String path = request.getServletPath();
+ // /api/ 경로가 아니거나 /api/auth/** 경로인 경우 필터 건너뜀
+ return !path.startsWith("/api/") || pathMatcher.match("/api/auth/**", path);
+ }
+
+ private String resolveToken(HttpServletRequest request) {
+ String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
+ if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
+ return bearerToken.substring(BEARER_PREFIX.length());
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java b/src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java
new file mode 100644
index 0000000..6ac24a8
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java
@@ -0,0 +1,107 @@
+package com.gcsc.connection.auth.jwt;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+@Slf4j
+@Component
+public class JwtTokenProvider {
+
+ private final SecretKey secretKey;
+ private final long accessTokenExpiration;
+ private final long refreshTokenExpiration;
+
+ public JwtTokenProvider(
+ @Value("${app.jwt.secret}") String secret,
+ @Value("${app.jwt.access-token-expiration}") long accessTokenExpiration,
+ @Value("${app.jwt.refresh-token-expiration}") long refreshTokenExpiration) {
+ this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ this.accessTokenExpiration = accessTokenExpiration;
+ this.refreshTokenExpiration = refreshTokenExpiration;
+ }
+
+ /**
+ * Access Token 생성
+ */
+ public String generateAccessToken(Long userId, String loginId, String role) {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + accessTokenExpiration);
+
+ return Jwts.builder()
+ .subject(String.valueOf(userId))
+ .claim("loginId", loginId)
+ .claim("role", role)
+ .issuedAt(now)
+ .expiration(expiry)
+ .signWith(secretKey)
+ .compact();
+ }
+
+ /**
+ * Refresh Token 생성
+ */
+ public String generateRefreshToken(Long userId) {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + refreshTokenExpiration);
+
+ return Jwts.builder()
+ .subject(String.valueOf(userId))
+ .issuedAt(now)
+ .expiration(expiry)
+ .signWith(secretKey)
+ .compact();
+ }
+
+ /**
+ * 토큰에서 Claims 파싱
+ */
+ public Claims parseClaims(String token) {
+ return Jwts.parser()
+ .verifyWith(secretKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+
+ /**
+ * 토큰 유효성 검증
+ */
+ public boolean validateToken(String token) {
+ try {
+ parseClaims(token);
+ return true;
+ } catch (ExpiredJwtException e) {
+ log.warn("만료된 JWT 토큰입니다");
+ } catch (Exception e) {
+ log.warn("유효하지 않은 JWT 토큰입니다: {}", e.getMessage());
+ }
+ return false;
+ }
+
+ /**
+ * 토큰에서 사용자 ID 추출
+ */
+ public Long getUserId(String token) {
+ return Long.parseLong(parseClaims(token).getSubject());
+ }
+
+ /**
+ * 토큰에서 로그인 ID 추출
+ */
+ public String getLoginId(String token) {
+ return parseClaims(token).get("loginId", String.class);
+ }
+
+ public long getAccessTokenExpiration() {
+ return accessTokenExpiration;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/auth/service/AuthService.java b/src/main/java/com/gcsc/connection/auth/service/AuthService.java
new file mode 100644
index 0000000..72cdaa8
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/auth/service/AuthService.java
@@ -0,0 +1,83 @@
+package com.gcsc.connection.auth.service;
+
+import com.gcsc.connection.auth.dto.LoginRequest;
+import com.gcsc.connection.auth.dto.LoginResponse;
+import com.gcsc.connection.auth.dto.TokenRefreshRequest;
+import com.gcsc.connection.auth.dto.TokenRefreshResponse;
+import com.gcsc.connection.auth.jwt.JwtTokenProvider;
+import com.gcsc.connection.common.exception.BusinessException;
+import com.gcsc.connection.common.exception.ErrorCode;
+import com.gcsc.connection.user.entity.SnpUser;
+import com.gcsc.connection.user.repository.SnpUserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+ private final SnpUserRepository userRepository;
+ private final JwtTokenProvider jwtTokenProvider;
+ private final PasswordEncoder passwordEncoder;
+
+ /**
+ * 로그인 처리: 사용자 인증 후 JWT 토큰 발급
+ */
+ @Transactional
+ public LoginResponse login(LoginRequest request) {
+ SnpUser user = userRepository.findByLoginId(request.loginId())
+ .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CREDENTIALS));
+
+ if (!Boolean.TRUE.equals(user.getIsActive())) {
+ throw new BusinessException(ErrorCode.USER_DISABLED);
+ }
+
+ if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
+ throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
+ }
+
+ String accessToken = jwtTokenProvider.generateAccessToken(
+ user.getUserId(), user.getLoginId(), user.getRole().name());
+ String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId());
+
+ user.updateLastLoginAt();
+
+ log.info("사용자 로그인 성공: {}", user.getLoginId());
+
+ return new LoginResponse(
+ accessToken,
+ refreshToken,
+ user.getLoginId(),
+ user.getUserName(),
+ user.getRole().name(),
+ jwtTokenProvider.getAccessTokenExpiration()
+ );
+ }
+
+ /**
+ * 토큰 갱신: Refresh Token으로 새 Access Token 발급
+ */
+ @Transactional(readOnly = true)
+ public TokenRefreshResponse refresh(TokenRefreshRequest request) {
+ if (!jwtTokenProvider.validateToken(request.refreshToken())) {
+ throw new BusinessException(ErrorCode.TOKEN_INVALID);
+ }
+
+ Long userId = jwtTokenProvider.getUserId(request.refreshToken());
+ SnpUser user = userRepository.findById(userId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
+
+ if (!Boolean.TRUE.equals(user.getIsActive())) {
+ throw new BusinessException(ErrorCode.USER_DISABLED);
+ }
+
+ String accessToken = jwtTokenProvider.generateAccessToken(
+ user.getUserId(), user.getLoginId(), user.getRole().name());
+
+ return new TokenRefreshResponse(accessToken, jwtTokenProvider.getAccessTokenExpiration());
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/common/dto/PageResponse.java b/src/main/java/com/gcsc/connection/common/dto/PageResponse.java
new file mode 100644
index 0000000..fda8b4b
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/common/dto/PageResponse.java
@@ -0,0 +1,24 @@
+package com.gcsc.connection.common.dto;
+
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+public record PageResponse(
+ List content,
+ int page,
+ int size,
+ long totalElements,
+ int totalPages
+) {
+
+ public static PageResponse from(Page page) {
+ return new PageResponse<>(
+ page.getContent(),
+ page.getNumber(),
+ page.getSize(),
+ page.getTotalElements(),
+ page.getTotalPages()
+ );
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/common/entity/BaseEntity.java b/src/main/java/com/gcsc/connection/common/entity/BaseEntity.java
new file mode 100644
index 0000000..576faca
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/common/entity/BaseEntity.java
@@ -0,0 +1,33 @@
+package com.gcsc.connection.common.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.PreUpdate;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@MappedSuperclass
+public abstract class BaseEntity {
+
+ @Column(name = "created_at", updatable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ @PrePersist
+ protected void onCreate() {
+ this.createdAt = LocalDateTime.now();
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ this.updatedAt = LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/common/exception/BusinessException.java b/src/main/java/com/gcsc/connection/common/exception/BusinessException.java
new file mode 100644
index 0000000..4d1633a
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/common/exception/BusinessException.java
@@ -0,0 +1,14 @@
+package com.gcsc.connection.common.exception;
+
+import lombok.Getter;
+
+@Getter
+public class BusinessException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public BusinessException(ErrorCode errorCode) {
+ super(errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java
new file mode 100644
index 0000000..850110c
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/common/exception/ErrorCode.java
@@ -0,0 +1,21 @@
+package com.gcsc.connection.common.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum ErrorCode {
+
+ INVALID_CREDENTIALS(401, "AUTH001", "아이디 또는 비밀번호가 올바르지 않습니다"),
+ TOKEN_EXPIRED(401, "AUTH002", "토큰이 만료되었습니다"),
+ TOKEN_INVALID(401, "AUTH003", "유효하지 않은 토큰입니다"),
+ ACCESS_DENIED(403, "AUTH004", "접근 권한이 없습니다"),
+ USER_NOT_FOUND(404, "USER001", "사용자를 찾을 수 없습니다"),
+ USER_DISABLED(403, "USER002", "비활성화된 사용자입니다"),
+ INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
+
+ private final int status;
+ private final String code;
+ private final String message;
+}
diff --git a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java
index ab87f26..bbbee81 100644
--- a/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/gcsc/connection/common/exception/GlobalExceptionHandler.java
@@ -4,13 +4,46 @@ import com.gcsc.connection.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import java.util.stream.Collectors;
+
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
+ /**
+ * 비즈니스 예외 처리
+ */
+ @ExceptionHandler(BusinessException.class)
+ public ResponseEntity> handleBusinessException(BusinessException e) {
+ ErrorCode errorCode = e.getErrorCode();
+ log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(ApiResponse.error(errorCode.getMessage()));
+ }
+
+ /**
+ * 요청 파라미터 유효성 검증 실패 처리
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity> handleValidationException(
+ MethodArgumentNotValidException e) {
+ String message = e.getBindingResult().getFieldErrors().stream()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .collect(Collectors.joining(", "));
+ log.warn("Validation failed: {}", message);
+ return ResponseEntity
+ .status(HttpStatus.BAD_REQUEST)
+ .body(ApiResponse.error(message));
+ }
+
+ /**
+ * 처리되지 않은 예외 처리
+ */
@ExceptionHandler(Exception.class)
public ResponseEntity> handleException(Exception e) {
log.error("Unhandled exception: {}", e.getMessage(), e);
diff --git a/src/main/java/com/gcsc/connection/config/SecurityConfig.java b/src/main/java/com/gcsc/connection/config/SecurityConfig.java
index 44f84ed..e86f6ab 100644
--- a/src/main/java/com/gcsc/connection/config/SecurityConfig.java
+++ b/src/main/java/com/gcsc/connection/config/SecurityConfig.java
@@ -1,25 +1,47 @@
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;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
+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
+@RequiredArgsConstructor
public class SecurityConfig {
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
+ .sessionManagement(session -> session
+ .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
- .anyRequest().permitAll());
+ .requestMatchers("/api/auth/**").permitAll()
+ .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
+ .requestMatchers("/actuator/**").permitAll()
+ .requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
+ .requestMatchers("/api/**").authenticated()
+ .anyRequest().permitAll())
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
diff --git a/src/main/java/com/gcsc/connection/monitoring/entity/SnpApiRequestLog.java b/src/main/java/com/gcsc/connection/monitoring/entity/SnpApiRequestLog.java
new file mode 100644
index 0000000..b50218b
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/monitoring/entity/SnpApiRequestLog.java
@@ -0,0 +1,104 @@
+package com.gcsc.connection.monitoring.entity;
+
+import com.gcsc.connection.apikey.entity.SnpApiKey;
+import com.gcsc.connection.service.entity.SnpService;
+import com.gcsc.connection.tenant.entity.SnpTenant;
+import com.gcsc.connection.user.entity.SnpUser;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_api_request_log", schema = "common")
+public class SnpApiRequestLog {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "log_id")
+ private Long logId;
+
+ @Column(name = "request_url", length = 2000)
+ private String requestUrl;
+
+ @Column(name = "request_params", columnDefinition = "TEXT")
+ private String requestParams;
+
+ @Column(name = "request_method", length = 10)
+ private String requestMethod;
+
+ @Column(name = "request_status", length = 20)
+ private String requestStatus;
+
+ @Column(name = "request_headers", columnDefinition = "TEXT")
+ private String requestHeaders;
+
+ @Column(name = "request_ip", length = 45)
+ private String requestIp;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "service_id")
+ private SnpService service;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private SnpUser user;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "api_key_id")
+ private SnpApiKey apiKey;
+
+ @Column(name = "response_size")
+ private Long responseSize;
+
+ @Column(name = "response_time")
+ private Integer responseTime;
+
+ @Column(name = "response_status")
+ private Integer responseStatus;
+
+ @Column(name = "error_message", columnDefinition = "TEXT")
+ private String errorMessage;
+
+ @Column(name = "requested_at", nullable = false)
+ private LocalDateTime requestedAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tenant_id")
+ private SnpTenant tenant;
+
+ @Builder
+ public SnpApiRequestLog(String requestUrl, String requestParams, String requestMethod,
+ String requestStatus, String requestHeaders, String requestIp,
+ SnpService service, SnpUser user, SnpApiKey apiKey,
+ Long responseSize, Integer responseTime, Integer responseStatus,
+ String errorMessage, LocalDateTime requestedAt, SnpTenant tenant) {
+ this.requestUrl = requestUrl;
+ this.requestParams = requestParams;
+ this.requestMethod = requestMethod;
+ this.requestStatus = requestStatus;
+ this.requestHeaders = requestHeaders;
+ this.requestIp = requestIp;
+ this.service = service;
+ this.user = user;
+ this.apiKey = apiKey;
+ this.responseSize = responseSize;
+ this.responseTime = responseTime;
+ this.responseStatus = responseStatus;
+ this.errorMessage = errorMessage;
+ this.requestedAt = requestedAt != null ? requestedAt : LocalDateTime.now();
+ this.tenant = tenant;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/monitoring/entity/SnpServiceHealthLog.java b/src/main/java/com/gcsc/connection/monitoring/entity/SnpServiceHealthLog.java
new file mode 100644
index 0000000..887770e
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/monitoring/entity/SnpServiceHealthLog.java
@@ -0,0 +1,59 @@
+package com.gcsc.connection.monitoring.entity;
+
+import com.gcsc.connection.service.entity.SnpService;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_service_health_log", schema = "common")
+public class SnpServiceHealthLog {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "log_id")
+ private Long logId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "service_id", nullable = false)
+ private SnpService service;
+
+ @Column(name = "previous_status", length = 10)
+ private String previousStatus;
+
+ @Column(name = "current_status", length = 10, nullable = false)
+ private String currentStatus;
+
+ @Column(name = "response_time")
+ private Integer responseTime;
+
+ @Column(name = "error_message", columnDefinition = "TEXT")
+ private String errorMessage;
+
+ @Column(name = "checked_at", nullable = false)
+ private LocalDateTime checkedAt;
+
+ @Builder
+ public SnpServiceHealthLog(SnpService service, String previousStatus, String currentStatus,
+ Integer responseTime, String errorMessage, LocalDateTime checkedAt) {
+ this.service = service;
+ this.previousStatus = previousStatus;
+ this.currentStatus = currentStatus;
+ this.responseTime = responseTime;
+ this.errorMessage = errorMessage;
+ this.checkedAt = checkedAt != null ? checkedAt : LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java
new file mode 100644
index 0000000..8edf50a
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java
@@ -0,0 +1,7 @@
+package com.gcsc.connection.monitoring.repository;
+
+import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface SnpApiRequestLogRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java
new file mode 100644
index 0000000..90925e3
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java
@@ -0,0 +1,7 @@
+package com.gcsc.connection.monitoring.repository;
+
+import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface SnpServiceHealthLogRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/gcsc/connection/service/entity/ServiceStatus.java b/src/main/java/com/gcsc/connection/service/entity/ServiceStatus.java
new file mode 100644
index 0000000..dfc04ca
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/service/entity/ServiceStatus.java
@@ -0,0 +1,7 @@
+package com.gcsc.connection.service.entity;
+
+public enum ServiceStatus {
+ UP,
+ DOWN,
+ UNKNOWN
+}
diff --git a/src/main/java/com/gcsc/connection/service/entity/SnpService.java b/src/main/java/com/gcsc/connection/service/entity/SnpService.java
new file mode 100644
index 0000000..6d10b04
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/service/entity/SnpService.java
@@ -0,0 +1,73 @@
+package com.gcsc.connection.service.entity;
+
+import com.gcsc.connection.common.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_service", schema = "common")
+public class SnpService extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "service_id")
+ private Long serviceId;
+
+ @Column(name = "service_code", length = 50, nullable = false, unique = true)
+ private String serviceCode;
+
+ @Column(name = "service_name", length = 200, nullable = false)
+ private String serviceName;
+
+ @Column(name = "service_url", length = 500)
+ private String serviceUrl;
+
+ @Column(name = "description", columnDefinition = "TEXT")
+ private String description;
+
+ @Column(name = "health_check_url", length = 500)
+ private String healthCheckUrl;
+
+ @Column(name = "health_check_interval", nullable = false)
+ private Integer healthCheckInterval = 30;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "health_status", length = 10, nullable = false)
+ private ServiceStatus healthStatus = ServiceStatus.UNKNOWN;
+
+ @Column(name = "health_checked_at")
+ private LocalDateTime healthCheckedAt;
+
+ @Column(name = "health_response_time")
+ private Integer healthResponseTime;
+
+ @Column(name = "is_active", nullable = false)
+ private Boolean isActive = true;
+
+ @Builder
+ public SnpService(String serviceCode, String serviceName, String serviceUrl,
+ String description, String healthCheckUrl, Integer healthCheckInterval,
+ ServiceStatus healthStatus, Boolean isActive) {
+ this.serviceCode = serviceCode;
+ this.serviceName = serviceName;
+ this.serviceUrl = serviceUrl;
+ this.description = description;
+ this.healthCheckUrl = healthCheckUrl;
+ this.healthCheckInterval = healthCheckInterval != null ? healthCheckInterval : 30;
+ this.healthStatus = healthStatus != null ? healthStatus : ServiceStatus.UNKNOWN;
+ this.isActive = isActive != null ? isActive : true;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/service/entity/SnpServiceApi.java b/src/main/java/com/gcsc/connection/service/entity/SnpServiceApi.java
new file mode 100644
index 0000000..83d9c79
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/service/entity/SnpServiceApi.java
@@ -0,0 +1,59 @@
+package com.gcsc.connection.service.entity;
+
+import com.gcsc.connection.common.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_service_api", schema = "common",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"service_id", "api_path", "api_method"}))
+public class SnpServiceApi extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "api_id")
+ private Long apiId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "service_id", nullable = false)
+ private SnpService service;
+
+ @Column(name = "api_path", length = 500, nullable = false)
+ private String apiPath;
+
+ @Column(name = "api_method", length = 10, nullable = false)
+ private String apiMethod;
+
+ @Column(name = "api_name", length = 200, nullable = false)
+ private String apiName;
+
+ @Column(name = "description", columnDefinition = "TEXT")
+ private String description;
+
+ @Column(name = "is_active", nullable = false)
+ private Boolean isActive = true;
+
+ @Builder
+ public SnpServiceApi(SnpService service, String apiPath, String apiMethod, String apiName,
+ String description, Boolean isActive) {
+ this.service = service;
+ this.apiPath = apiPath;
+ this.apiMethod = apiMethod;
+ this.apiName = apiName;
+ this.description = description;
+ this.isActive = isActive != null ? isActive : true;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java
new file mode 100644
index 0000000..15c09ce
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java
@@ -0,0 +1,12 @@
+package com.gcsc.connection.service.repository;
+
+import com.gcsc.connection.service.entity.SnpService;
+import com.gcsc.connection.service.entity.SnpServiceApi;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface SnpServiceApiRepository extends JpaRepository {
+
+ List findByService(SnpService service);
+}
diff --git a/src/main/java/com/gcsc/connection/service/repository/SnpServiceRepository.java b/src/main/java/com/gcsc/connection/service/repository/SnpServiceRepository.java
new file mode 100644
index 0000000..e0fbce9
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/service/repository/SnpServiceRepository.java
@@ -0,0 +1,11 @@
+package com.gcsc.connection.service.repository;
+
+import com.gcsc.connection.service.entity.SnpService;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface SnpServiceRepository extends JpaRepository {
+
+ Optional findByServiceCode(String serviceCode);
+}
diff --git a/src/main/java/com/gcsc/connection/tenant/entity/SnpTenant.java b/src/main/java/com/gcsc/connection/tenant/entity/SnpTenant.java
new file mode 100644
index 0000000..0ce680e
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/tenant/entity/SnpTenant.java
@@ -0,0 +1,44 @@
+package com.gcsc.connection.tenant.entity;
+
+import com.gcsc.connection.common.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_tenant", schema = "common")
+public class SnpTenant extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "tenant_id")
+ private Long tenantId;
+
+ @Column(name = "tenant_code", length = 50, nullable = false, unique = true)
+ private String tenantCode;
+
+ @Column(name = "tenant_name", length = 200, nullable = false)
+ private String tenantName;
+
+ @Column(name = "description", columnDefinition = "TEXT")
+ private String description;
+
+ @Column(name = "is_active", nullable = false)
+ private Boolean isActive = true;
+
+ @Builder
+ public SnpTenant(String tenantCode, String tenantName, String description, Boolean isActive) {
+ this.tenantCode = tenantCode;
+ this.tenantName = tenantName;
+ this.description = description;
+ this.isActive = isActive != null ? isActive : true;
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/tenant/repository/SnpTenantRepository.java b/src/main/java/com/gcsc/connection/tenant/repository/SnpTenantRepository.java
new file mode 100644
index 0000000..5b47e07
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/tenant/repository/SnpTenantRepository.java
@@ -0,0 +1,11 @@
+package com.gcsc.connection.tenant.repository;
+
+import com.gcsc.connection.tenant.entity.SnpTenant;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface SnpTenantRepository extends JpaRepository {
+
+ Optional findByTenantCode(String tenantCode);
+}
diff --git a/src/main/java/com/gcsc/connection/user/entity/SnpUser.java b/src/main/java/com/gcsc/connection/user/entity/SnpUser.java
new file mode 100644
index 0000000..69220e7
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/user/entity/SnpUser.java
@@ -0,0 +1,74 @@
+package com.gcsc.connection.user.entity;
+
+import com.gcsc.connection.common.entity.BaseEntity;
+import com.gcsc.connection.tenant.entity.SnpTenant;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Entity
+@Table(name = "snp_user", schema = "common")
+public class SnpUser extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "user_id")
+ private Long userId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tenant_id")
+ private SnpTenant tenant;
+
+ @Column(name = "login_id", length = 100, nullable = false, unique = true)
+ private String loginId;
+
+ @Column(name = "password_hash", length = 255, nullable = false)
+ private String passwordHash;
+
+ @Column(name = "user_name", length = 100, nullable = false)
+ private String userName;
+
+ @Column(name = "email", length = 200)
+ private String email;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "role", length = 20, nullable = false)
+ private UserRole role = UserRole.USER;
+
+ @Column(name = "is_active", nullable = false)
+ private Boolean isActive = true;
+
+ @Column(name = "last_login_at")
+ private LocalDateTime lastLoginAt;
+
+ @Builder
+ public SnpUser(SnpTenant tenant, String loginId, String passwordHash, String userName,
+ String email, UserRole role, Boolean isActive) {
+ this.tenant = tenant;
+ this.loginId = loginId;
+ this.passwordHash = passwordHash;
+ this.userName = userName;
+ this.email = email;
+ this.role = role != null ? role : UserRole.USER;
+ this.isActive = isActive != null ? isActive : true;
+ }
+
+ public void updateLastLoginAt() {
+ this.lastLoginAt = LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/com/gcsc/connection/user/entity/UserRole.java b/src/main/java/com/gcsc/connection/user/entity/UserRole.java
new file mode 100644
index 0000000..9fc9a9b
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/user/entity/UserRole.java
@@ -0,0 +1,8 @@
+package com.gcsc.connection.user.entity;
+
+public enum UserRole {
+ ADMIN,
+ MANAGER,
+ USER,
+ VIEWER
+}
diff --git a/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java b/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java
new file mode 100644
index 0000000..6802372
--- /dev/null
+++ b/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java
@@ -0,0 +1,13 @@
+package com.gcsc.connection.user.repository;
+
+import com.gcsc.connection.user.entity.SnpUser;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface SnpUserRepository extends JpaRepository {
+
+ Optional findByLoginId(String loginId);
+
+ boolean existsByLoginId(String loginId);
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index dec39a7..ae1617b 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -4,8 +4,8 @@ spring:
# PostgreSQL Database Configuration
datasource:
- url: jdbc:postgresql://211.208.115.83:5432/snpdb
- username: snp
+ url: jdbc:postgresql://211.208.115.83:5432/snp_connection
+ username: snp_admin
password: snp#8932
driver-class-name: org.postgresql.Driver
hikari:
@@ -16,13 +16,13 @@ spring:
# JPA Configuration
jpa:
hibernate:
- ddl-auto: validate
+ ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
- default_schema: std_snp_connection
+ default_schema: common
# Server Configuration
server:
@@ -58,3 +58,7 @@ app:
heartbeat:
default-interval-seconds: 30
timeout-seconds: 5
+ jwt:
+ secret: c25wLWNvbm5lY3Rpb24tbW9uaXRvcmluZy1qd3Qtc2VjcmV0LWtleS0yMDI2
+ access-token-expiration: 3600000
+ refresh-token-expiration: 604800000