generated from gc/template-java-maven
Merge pull request 'feat(phase1): 기반 구축 - DB Entity, JWT 인증, 프론트엔드 레이아웃' (#12) from feature/ISSUE-6-phase1-foundation into develop
This commit is contained in:
커밋
4955944478
@ -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)
|
||||
|
||||
187
docs/schema/create_tables.sql
Normal file
187
docs/schema/create_tables.sql
Normal file
@ -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);
|
||||
24
docs/schema/initial_data.sql
Normal file
24
docs/schema/initial_data.sql
Normal file
@ -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;
|
||||
@ -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 (
|
||||
<BrowserRouter basename={BASE_PATH}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">SNP Connection Monitoring</h1>
|
||||
<p className="mt-2 text-gray-600">Dashboard - Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<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="/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>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
22
frontend/src/components/ProtectedRoute.tsx
Normal file
22
frontend/src/components/ProtectedRoute.tsx
Normal file
@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
8
frontend/src/hooks/useAuth.ts
Normal file
8
frontend/src/hooks/useAuth.ts
Normal file
@ -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;
|
||||
};
|
||||
11
frontend/src/layouts/AuthLayout.tsx
Normal file
11
frontend/src/layouts/AuthLayout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const AuthLayout = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
166
frontend/src/layouts/MainLayout.tsx
Normal file
166
frontend/src/layouts/MainLayout.tsx
Normal file
@ -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<Record<string, boolean>>({
|
||||
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 (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
|
||||
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-700">
|
||||
<svg className="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="text-lg font-semibold">SNP Connection</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
{/* Dashboard */}
|
||||
<NavLink
|
||||
to="/dashboard"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
|
||||
{/* Nav Groups */}
|
||||
{navGroups.map((group) => {
|
||||
if (group.adminOnly && user?.role !== 'ADMIN') return null;
|
||||
|
||||
const isOpen = openGroups[group.label] ?? false;
|
||||
|
||||
return (
|
||||
<div key={group.label} className="mt-4">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-white"
|
||||
>
|
||||
{group.label}
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ml-2 space-y-1">
|
||||
{group.items.map((item) => {
|
||||
if (
|
||||
group.label === 'API Keys' &&
|
||||
item.label === 'Admin' &&
|
||||
!isAdminOrManager
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 ml-64">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-700">{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 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
10
frontend/src/pages/DashboardPage.tsx
Normal file
10
frontend/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const DashboardPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
93
frontend/src/pages/LoginPage.tsx
Normal file
93
frontend/src/pages/LoginPage.tsx
Normal file
@ -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 (
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-xl bg-white px-8 py-10 shadow-lg">
|
||||
<h1 className="mb-8 text-center text-2xl font-bold text-gray-900">
|
||||
SNP Connection Monitoring
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="loginId" className="block text-sm font-medium text-gray-700 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 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 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 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;
|
||||
18
frontend/src/pages/NotFoundPage.tsx
Normal file
18
frontend/src/pages/NotFoundPage.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const NotFoundPage = () => {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900">404 - Page Not Found</h1>
|
||||
<p className="mt-3 text-gray-600">요청하신 페이지를 찾을 수 없습니다.</p>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="mt-6 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Dashboard로 이동
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
10
frontend/src/pages/admin/ServicesPage.tsx
Normal file
10
frontend/src/pages/admin/ServicesPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const ServicesPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesPage;
|
||||
10
frontend/src/pages/admin/TenantsPage.tsx
Normal file
10
frontend/src/pages/admin/TenantsPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const TenantsPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantsPage;
|
||||
10
frontend/src/pages/admin/UsersPage.tsx
Normal file
10
frontend/src/pages/admin/UsersPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const UsersPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
10
frontend/src/pages/apikeys/KeyAdminPage.tsx
Normal file
10
frontend/src/pages/apikeys/KeyAdminPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const KeyAdminPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">API Key Admin</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyAdminPage;
|
||||
10
frontend/src/pages/apikeys/KeyRequestPage.tsx
Normal file
10
frontend/src/pages/apikeys/KeyRequestPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const KeyRequestPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">API Key Request</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyRequestPage;
|
||||
10
frontend/src/pages/apikeys/MyKeysPage.tsx
Normal file
10
frontend/src/pages/apikeys/MyKeysPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const MyKeysPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">My API Keys</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyKeysPage;
|
||||
10
frontend/src/pages/monitoring/RequestLogsPage.tsx
Normal file
10
frontend/src/pages/monitoring/RequestLogsPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const RequestLogsPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Request Logs</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestLogsPage;
|
||||
116
frontend/src/services/apiClient.ts
Normal file
116
frontend/src/services/apiClient.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import type { ApiResponse } from '../types/api';
|
||||
|
||||
const BASE_URL = '/snp-connection/api';
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | 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<boolean> => {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) return false;
|
||||
|
||||
const data = await response.json() as ApiResponse<{ accessToken: string }>;
|
||||
if (data.success && data.data?.accessToken) {
|
||||
localStorage.setItem('snp_access_token', data.data.accessToken);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenRefresh = async (): Promise<boolean> => {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = attemptRefresh().finally(() => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
const request = async <T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> => {
|
||||
const token = getAccessToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
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<T>;
|
||||
return data;
|
||||
};
|
||||
|
||||
export const get = <T>(url: string): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, { method: 'GET' });
|
||||
};
|
||||
|
||||
export const post = <T>(url: string, body?: unknown): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const put = <T>(url: string, body?: unknown): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const del = <T>(url: string): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, { method: 'DELETE' });
|
||||
};
|
||||
67
frontend/src/services/authService.ts
Normal file
67
frontend/src/services/authService.ts
Normal file
@ -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<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);
|
||||
};
|
||||
78
frontend/src/store/AuthContext.tsx
Normal file
78
frontend/src/store/AuthContext.tsx
Normal file
@ -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<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
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 (
|
||||
<AuthContext.Provider value={{ ...state, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
5
frontend/src/types/api.ts
Normal file
5
frontend/src/types/api.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
19
frontend/src/types/auth.ts
Normal file
19
frontend/src/types/auth.ts
Normal file
@ -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;
|
||||
}
|
||||
19
pom.xml
19
pom.xml
@ -104,6 +104,25 @@
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT (jjwt) -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Test Dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package com.gcsc.connection.apikey.entity;
|
||||
|
||||
public enum ApiKeyStatus {
|
||||
PENDING,
|
||||
ACTIVE,
|
||||
INACTIVE,
|
||||
EXPIRED,
|
||||
REVOKED
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.gcsc.connection.apikey.entity;
|
||||
|
||||
public enum KeyRequestStatus {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<SnpApiKey, Long> {
|
||||
|
||||
Optional<SnpApiKey> findByApiKey(String apiKey);
|
||||
}
|
||||
@ -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<SnpApiKeyRequest, Long> {
|
||||
}
|
||||
@ -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<SnpApiPermission, Long> {
|
||||
}
|
||||
@ -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<ApiResponse<LoginResponse>> login(
|
||||
@RequestBody @Valid LoginRequest request) {
|
||||
LoginResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<ApiResponse<Void>> logout() {
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "로그아웃되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 갱신 - Refresh Token으로 새 Access Token 발급
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<ApiResponse<TokenRefreshResponse>> refresh(
|
||||
@RequestBody @Valid TokenRefreshRequest request) {
|
||||
TokenRefreshResponse response = authService.refresh(request);
|
||||
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.gcsc.connection.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank String loginId,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.gcsc.connection.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record TokenRefreshRequest(
|
||||
@NotBlank String refreshToken
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.gcsc.connection.auth.dto;
|
||||
|
||||
public record TokenRefreshResponse(
|
||||
String accessToken,
|
||||
long expiresIn
|
||||
) {
|
||||
}
|
||||
@ -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<SimpleGrantedAuthority> 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;
|
||||
}
|
||||
}
|
||||
107
src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java
Normal file
107
src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.gcsc.connection.common.dto;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> content,
|
||||
int page,
|
||||
int size,
|
||||
long totalElements,
|
||||
int totalPages
|
||||
) {
|
||||
|
||||
public static <T> PageResponse<T> from(Page<T> page) {
|
||||
return new PageResponse<>(
|
||||
page.getContent(),
|
||||
page.getNumber(),
|
||||
page.getSize(),
|
||||
page.getTotalElements(),
|
||||
page.getTotalPages()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> handleException(Exception e) {
|
||||
log.error("Unhandled exception: {}", e.getMessage(), e);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<SnpApiRequestLog, Long> {
|
||||
}
|
||||
@ -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<SnpServiceHealthLog, Long> {
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.gcsc.connection.service.entity;
|
||||
|
||||
public enum ServiceStatus {
|
||||
UP,
|
||||
DOWN,
|
||||
UNKNOWN
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<SnpServiceApi, Long> {
|
||||
|
||||
List<SnpServiceApi> findByService(SnpService service);
|
||||
}
|
||||
@ -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<SnpService, Long> {
|
||||
|
||||
Optional<SnpService> findByServiceCode(String serviceCode);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<SnpTenant, Long> {
|
||||
|
||||
Optional<SnpTenant> findByTenantCode(String tenantCode);
|
||||
}
|
||||
74
src/main/java/com/gcsc/connection/user/entity/SnpUser.java
Normal file
74
src/main/java/com/gcsc/connection/user/entity/SnpUser.java
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.gcsc.connection.user.entity;
|
||||
|
||||
public enum UserRole {
|
||||
ADMIN,
|
||||
MANAGER,
|
||||
USER,
|
||||
VIEWER
|
||||
}
|
||||
@ -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<SnpUser, Long> {
|
||||
|
||||
Optional<SnpUser> findByLoginId(String loginId);
|
||||
|
||||
boolean existsByLoginId(String loginId);
|
||||
}
|
||||
@ -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
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user