Merge pull request 'feat(phase2): 핵심 관리 기능 CRUD + 하트비트 스케줄러' (#13) from feature/ISSUE-7-phase2-crud-heartbeat into develop

This commit is contained in:
HYOJIN 2026-04-07 17:06:39 +09:00
커밋 d4ccb1f4c6
45개의 변경된 파일2447개의 추가작업 그리고 6개의 파일을 삭제

파일 보기

@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased]
### 추가
- 테넌트 CRUD API (GET/POST/PUT /api/tenants) (#7)
- 사용자 CRUD API (역할 기반 접근 제어, GET/POST/PUT/DELETE /api/users) (#7)
- 서비스 등록/관리 API (GET/POST/PUT /api/services, /api/services/{id}/apis) (#7)
- 하트비트 스케줄러 (30초 간격 폴링, WebClient 헬스체크) (#7)
- 헬스체크 상태 조회/이력 API (GET/POST /api/heartbeat) (#7)
- @EnableMethodSecurity + @PreAuthorize 역할 기반 접근 제어 (#7)
- 테넌트/사용자/서비스 관리 프론트엔드 페이지 (CRUD 테이블 + 모달) (#7)
## [2026-04-07]
### 추가

파일 보기

@ -1,8 +1,573 @@
import { useState, useEffect } from 'react';
import type {
ServiceInfo,
ServiceApi,
CreateServiceRequest,
UpdateServiceRequest,
CreateServiceApiRequest,
} from '../../types/service';
import {
getServices,
createService,
updateService,
getServiceApis,
createServiceApi,
} from '../../services/serviceService';
const HEALTH_BADGE: Record<string, { dot: string; bg: string; text: string }> = {
UP: { dot: 'bg-green-500', bg: 'bg-green-100', text: 'text-green-800' },
DOWN: { dot: 'bg-red-500', bg: 'bg-red-100', text: 'text-red-800' },
UNKNOWN: { dot: 'bg-gray-400', bg: 'bg-gray-100', text: 'text-gray-800' },
};
const METHOD_COLOR: Record<string, string> = {
GET: 'bg-green-100 text-green-800',
POST: 'bg-blue-100 text-blue-800',
PUT: 'bg-orange-100 text-orange-800',
DELETE: 'bg-red-100 text-red-800',
};
const formatRelativeTime = (dateStr: string | null): string => {
if (!dateStr) return '-';
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}초 전`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}분 전`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
return `${days}일 전`;
};
const ServicesPage = () => {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [selectedService, setSelectedService] = useState<ServiceInfo | null>(null);
const [serviceApis, setServiceApis] = useState<ServiceApi[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
const [editingService, setEditingService] = useState<ServiceInfo | null>(null);
const [serviceCode, setServiceCode] = useState('');
const [serviceName, setServiceName] = useState('');
const [serviceUrl, setServiceUrl] = useState('');
const [serviceDescription, setServiceDescription] = useState('');
const [healthCheckUrl, setHealthCheckUrl] = useState('');
const [healthCheckInterval, setHealthCheckInterval] = useState(60);
const [serviceIsActive, setServiceIsActive] = useState(true);
const [isApiModalOpen, setIsApiModalOpen] = useState(false);
const [apiMethod, setApiMethod] = useState('GET');
const [apiPath, setApiPath] = useState('');
const [apiName, setApiName] = useState('');
const [apiDescription, setApiDescription] = useState('');
const fetchServices = async () => {
try {
setLoading(true);
const res = await getServices();
if (res.success && res.data) {
setServices(res.data);
} else {
setError(res.message || '서비스 목록을 불러오는데 실패했습니다.');
}
} catch {
setError('서비스 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const fetchApis = async (serviceId: number) => {
try {
const res = await getServiceApis(serviceId);
if (res.success && res.data) {
setServiceApis(res.data);
}
} catch {
setServiceApis([]);
}
};
useEffect(() => {
fetchServices();
}, []);
const handleSelectService = (service: ServiceInfo) => {
setSelectedService(service);
fetchApis(service.serviceId);
};
const handleOpenCreateService = () => {
setEditingService(null);
setServiceCode('');
setServiceName('');
setServiceUrl('');
setServiceDescription('');
setHealthCheckUrl('');
setHealthCheckInterval(60);
setServiceIsActive(true);
setIsServiceModalOpen(true);
};
const handleOpenEditService = (service: ServiceInfo) => {
setEditingService(service);
setServiceCode(service.serviceCode);
setServiceName(service.serviceName);
setServiceUrl(service.serviceUrl || '');
setServiceDescription(service.description || '');
setHealthCheckUrl(service.healthCheckUrl || '');
setHealthCheckInterval(service.healthCheckInterval);
setServiceIsActive(service.isActive);
setIsServiceModalOpen(true);
};
const handleCloseServiceModal = () => {
setIsServiceModalOpen(false);
setEditingService(null);
setError(null);
};
const handleServiceSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingService) {
const req: UpdateServiceRequest = {
serviceName,
serviceUrl: serviceUrl || undefined,
description: serviceDescription || undefined,
healthCheckUrl: healthCheckUrl || undefined,
healthCheckInterval,
isActive: serviceIsActive,
};
const res = await updateService(editingService.serviceId, req);
if (!res.success) {
setError(res.message || '서비스 수정에 실패했습니다.');
return;
}
} else {
const req: CreateServiceRequest = {
serviceCode,
serviceName,
serviceUrl: serviceUrl || undefined,
description: serviceDescription || undefined,
healthCheckUrl: healthCheckUrl || undefined,
healthCheckInterval,
};
const res = await createService(req);
if (!res.success) {
setError(res.message || '서비스 생성에 실패했습니다.');
return;
}
}
handleCloseServiceModal();
await fetchServices();
} catch {
setError(editingService ? '서비스 수정에 실패했습니다.' : '서비스 생성에 실패했습니다.');
}
};
const handleOpenCreateApi = () => {
setApiMethod('GET');
setApiPath('');
setApiName('');
setApiDescription('');
setIsApiModalOpen(true);
};
const handleCloseApiModal = () => {
setIsApiModalOpen(false);
setError(null);
};
const handleApiSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedService) return;
setError(null);
try {
const req: CreateServiceApiRequest = {
apiMethod,
apiPath,
apiName,
description: apiDescription || undefined,
};
const res = await createServiceApi(selectedService.serviceId, req);
if (!res.success) {
setError(res.message || 'API 생성에 실패했습니다.');
return;
}
handleCloseApiModal();
await fetchApis(selectedService.serviceId);
} catch {
setError('API 생성에 실패했습니다.');
}
};
if (loading) {
return <div className="text-center py-10 text-gray-500"> ...</div>;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
<button
onClick={handleOpenCreateService}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Create Service
</button>
</div>
{error && !isServiceModalOpen && !isApiModalOpen && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="overflow-x-auto bg-white rounded-lg shadow mb-6">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Code</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">URL</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Health Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Response Time</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Checked</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{services.map((service) => {
const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN;
const isSelected = selectedService?.serviceId === service.serviceId;
return (
<tr
key={service.serviceId}
onClick={() => handleSelectService(service)}
className={`cursor-pointer ${
isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<td className="px-4 py-3 font-mono">{service.serviceCode}</td>
<td className="px-4 py-3">{service.serviceName}</td>
<td className="px-4 py-3 text-gray-500 truncate max-w-[200px]">
{service.serviceUrl || '-'}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${badge.bg} ${badge.text}`}
>
<span className={`w-2 h-2 rounded-full ${badge.dot}`} />
{service.healthStatus}
</span>
</td>
<td className="px-4 py-3 text-gray-500">
{service.healthResponseTime != null
? `${service.healthResponseTime}ms`
: '-'}
</td>
<td className="px-4 py-3 text-gray-500">
{formatRelativeTime(service.healthCheckedAt)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
service.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{service.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={(e) => {
e.stopPropagation();
handleOpenEditService(service);
}}
className="text-blue-600 hover:text-blue-800 font-medium"
>
Edit
</button>
</td>
</tr>
);
})}
{services.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
{selectedService && (
<div className="bg-white rounded-lg shadow">
<div className="flex items-center justify-between px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
APIs for {selectedService.serviceName}
</h2>
<button
onClick={handleOpenCreateApi}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium"
>
Add API
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Method</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Path</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Description</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{serviceApis.map((api) => (
<tr key={api.apiId} className="hover:bg-gray-50">
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
}`}
>
{api.apiMethod}
</span>
</td>
<td className="px-4 py-3 font-mono">{api.apiPath}</td>
<td className="px-4 py-3">{api.apiName}</td>
<td className="px-4 py-3 text-gray-500">{api.description || '-'}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
api.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{api.isActive ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
))}
{serviceApis.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
API가 .
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{isServiceModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
{editingService ? '서비스 수정' : '서비스 생성'}
</h2>
</div>
<form onSubmit={handleServiceSubmit}>
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Service Code
</label>
<input
type="text"
value={serviceCode}
onChange={(e) => setServiceCode(e.target.value)}
disabled={!!editingService}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Service Name
</label>
<input
type="text"
value={serviceName}
onChange={(e) => setServiceName(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Service URL
</label>
<input
type="text"
value={serviceUrl}
onChange={(e) => setServiceUrl(e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={serviceDescription}
onChange={(e) => setServiceDescription(e.target.value)}
rows={3}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Health Check URL
</label>
<input
type="text"
value={healthCheckUrl}
onChange={(e) => setHealthCheckUrl(e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Health Check Interval (seconds)
</label>
<input
type="number"
value={healthCheckInterval}
onChange={(e) => setHealthCheckInterval(Number(e.target.value))}
min={10}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
{editingService && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="serviceIsActive"
checked={serviceIsActive}
onChange={(e) => setServiceIsActive(e.target.checked)}
className="rounded"
/>
<label htmlFor="serviceIsActive" className="text-sm text-gray-700">
Active
</label>
</div>
)}
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={handleCloseServiceModal}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Save
</button>
</div>
</form>
</div>
</div>
)}
{isApiModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">API </h2>
</div>
<form onSubmit={handleApiSubmit}>
<div className="px-6 py-4 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Method</label>
<select
value={apiMethod}
onChange={(e) => setApiMethod(e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Path</label>
<input
type="text"
value={apiPath}
onChange={(e) => setApiPath(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Name</label>
<input
type="text"
value={apiName}
onChange={(e) => setApiName(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={apiDescription}
onChange={(e) => setApiDescription(e.target.value)}
rows={3}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={handleCloseApiModal}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

파일 보기

@ -1,8 +1,254 @@
import { useState, useEffect } from 'react';
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../../types/tenant';
import { getTenants, createTenant, updateTenant } from '../../services/tenantService';
const TenantsPage = () => {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [tenantCode, setTenantCode] = useState('');
const [tenantName, setTenantName] = useState('');
const [description, setDescription] = useState('');
const [isActive, setIsActive] = useState(true);
const fetchTenants = async () => {
try {
setLoading(true);
const res = await getTenants();
if (res.success && res.data) {
setTenants(res.data);
} else {
setError(res.message || '테넌트 목록을 불러오는데 실패했습니다.');
}
} catch {
setError('테넌트 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTenants();
}, []);
const handleOpenCreate = () => {
setEditingTenant(null);
setTenantCode('');
setTenantName('');
setDescription('');
setIsActive(true);
setIsModalOpen(true);
};
const handleOpenEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
setTenantCode(tenant.tenantCode);
setTenantName(tenant.tenantName);
setDescription(tenant.description || '');
setIsActive(tenant.isActive);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingTenant(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingTenant) {
const req: UpdateTenantRequest = {
tenantName,
description: description || undefined,
isActive,
};
const res = await updateTenant(editingTenant.tenantId, req);
if (!res.success) {
setError(res.message || '테넌트 수정에 실패했습니다.');
return;
}
} else {
const req: CreateTenantRequest = {
tenantCode,
tenantName,
description: description || undefined,
};
const res = await createTenant(req);
if (!res.success) {
setError(res.message || '테넌트 생성에 실패했습니다.');
return;
}
}
handleCloseModal();
await fetchTenants();
} catch {
setError(editingTenant ? '테넌트 수정에 실패했습니다.' : '테넌트 생성에 실패했습니다.');
}
};
if (loading) {
return <div className="text-center py-10 text-gray-500"> ...</div>;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
<button
onClick={handleOpenCreate}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Create Tenant
</button>
</div>
{error && !isModalOpen && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="overflow-x-auto bg-white rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Code</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Description</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Created</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{tenants.map((tenant) => (
<tr key={tenant.tenantId} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono">{tenant.tenantCode}</td>
<td className="px-4 py-3">{tenant.tenantName}</td>
<td className="px-4 py-3 text-gray-500">{tenant.description || '-'}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
tenant.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{tenant.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-gray-500">
{new Date(tenant.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<button
onClick={() => handleOpenEdit(tenant)}
className="text-blue-600 hover:text-blue-800 font-medium"
>
Edit
</button>
</td>
</tr>
))}
{tenants.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
{editingTenant ? '테넌트 수정' : '테넌트 생성'}
</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tenant Code
</label>
<input
type="text"
value={tenantCode}
onChange={(e) => setTenantCode(e.target.value)}
disabled={!!editingTenant}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tenant Name
</label>
<input
type="text"
value={tenantName}
onChange={(e) => setTenantName(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
{editingTenant && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="rounded"
/>
<label htmlFor="isActive" className="text-sm text-gray-700">
Active
</label>
</div>
)}
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={handleCloseModal}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

파일 보기

@ -1,8 +1,350 @@
import { useState, useEffect } from 'react';
import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../../types/user';
import type { Tenant } from '../../types/tenant';
import { getUsers, createUser, updateUser, deactivateUser } from '../../services/userService';
import { getTenants } from '../../services/tenantService';
const ROLE_BADGE: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-orange-100 text-orange-800',
USER: 'bg-blue-100 text-blue-800',
VIEWER: 'bg-gray-100 text-gray-800',
};
const UsersPage = () => {
const [users, setUsers] = useState<UserDetail[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
const [loginId, setLoginId] = useState('');
const [password, setPassword] = useState('');
const [userName, setUserName] = useState('');
const [email, setEmail] = useState('');
const [tenantId, setTenantId] = useState<string>('');
const [role, setRole] = useState('USER');
const [isActive, setIsActive] = useState(true);
const fetchData = async () => {
try {
setLoading(true);
const [usersRes, tenantsRes] = await Promise.all([getUsers(), getTenants()]);
if (usersRes.success && usersRes.data) {
setUsers(usersRes.data);
}
if (tenantsRes.success && tenantsRes.data) {
setTenants(tenantsRes.data);
}
} catch {
setError('데이터를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleOpenCreate = () => {
setEditingUser(null);
setLoginId('');
setPassword('');
setUserName('');
setEmail('');
setTenantId('');
setRole('USER');
setIsActive(true);
setIsModalOpen(true);
};
const handleOpenEdit = (user: UserDetail) => {
setEditingUser(user);
setLoginId(user.loginId);
setPassword('');
setUserName(user.userName);
setEmail(user.email || '');
setTenantId(user.tenantId ? String(user.tenantId) : '');
setRole(user.role);
setIsActive(user.isActive);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingUser(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingUser) {
const req: UpdateUserRequest = {
tenantId: tenantId ? Number(tenantId) : undefined,
userName,
email: email || undefined,
role,
password: password || undefined,
isActive,
};
const res = await updateUser(editingUser.userId, req);
if (!res.success) {
setError(res.message || '사용자 수정에 실패했습니다.');
return;
}
} else {
const req: CreateUserRequest = {
tenantId: tenantId ? Number(tenantId) : undefined,
loginId,
password,
userName,
email: email || undefined,
role,
};
const res = await createUser(req);
if (!res.success) {
setError(res.message || '사용자 생성에 실패했습니다.');
return;
}
}
handleCloseModal();
await fetchData();
} catch {
setError(editingUser ? '사용자 수정에 실패했습니다.' : '사용자 생성에 실패했습니다.');
}
};
const handleDeactivate = async (user: UserDetail) => {
if (!confirm(`'${user.userName}' 사용자를 비활성화하시겠습니까?`)) return;
try {
const res = await deactivateUser(user.userId);
if (!res.success) {
setError(res.message || '사용자 비활성화에 실패했습니다.');
return;
}
await fetchData();
} catch {
setError('사용자 비활성화에 실패했습니다.');
}
};
if (loading) {
return <div className="text-center py-10 text-gray-500"> ...</div>;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
<button
onClick={handleOpenCreate}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Create User
</button>
</div>
{error && !isModalOpen && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="overflow-x-auto bg-white rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Login ID</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Email</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Tenant</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Role</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Login</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.userId} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono">{user.loginId}</td>
<td className="px-4 py-3">{user.userName}</td>
<td className="px-4 py-3 text-gray-500">{user.email || '-'}</td>
<td className="px-4 py-3 text-gray-500">{user.tenantName || '-'}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
ROLE_BADGE[user.role] || 'bg-gray-100 text-gray-800'
}`}
>
{user.role}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
user.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-gray-500">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleString()
: '-'}
</td>
<td className="px-4 py-3 space-x-2">
<button
onClick={() => handleOpenEdit(user)}
className="text-blue-600 hover:text-blue-800 font-medium"
>
Edit
</button>
{user.isActive && (
<button
onClick={() => handleDeactivate(user)}
className="text-red-600 hover:text-red-800 font-medium"
>
</button>
)}
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
{editingUser ? '사용자 수정' : '사용자 생성'}
</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Login ID</label>
<input
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
disabled={!!editingUser}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={!editingUser}
placeholder={editingUser ? '변경 시 입력' : ''}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User Name</label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tenant</label>
<select
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="">-- --</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.tenantName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="ADMIN">ADMIN</option>
<option value="MANAGER">MANAGER</option>
<option value="USER">USER</option>
<option value="VIEWER">VIEWER</option>
</select>
</div>
{editingUser && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="rounded"
/>
<label htmlFor="isActive" className="text-sm text-gray-700">
Active
</label>
</div>
)}
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={handleCloseModal}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

파일 보기

@ -0,0 +1,6 @@
import { get, post } from './apiClient';
import type { HeartbeatStatus, HealthHistory } from '../types/service';
export const getHeartbeatStatus = () => get<HeartbeatStatus[]>('/heartbeat/status');
export const getHealthHistory = (serviceId: number) => get<HealthHistory[]>(`/heartbeat/${serviceId}/history`);
export const triggerCheck = (serviceId: number) => post<HeartbeatStatus>(`/heartbeat/${serviceId}/check`);

파일 보기

@ -0,0 +1,15 @@
import { get, post, put } from './apiClient';
import type {
ServiceInfo,
ServiceApi,
CreateServiceRequest,
UpdateServiceRequest,
CreateServiceApiRequest,
} from '../types/service';
export const getServices = () => get<ServiceInfo[]>('/services');
export const createService = (req: CreateServiceRequest) => post<ServiceInfo>('/services', req);
export const updateService = (id: number, req: UpdateServiceRequest) => put<ServiceInfo>(`/services/${id}`, req);
export const getServiceApis = (serviceId: number) => get<ServiceApi[]>(`/services/${serviceId}/apis`);
export const createServiceApi = (serviceId: number, req: CreateServiceApiRequest) =>
post<ServiceApi>(`/services/${serviceId}/apis`, req);

파일 보기

@ -0,0 +1,6 @@
import { get, post, put } from './apiClient';
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../types/tenant';
export const getTenants = () => get<Tenant[]>('/tenants');
export const createTenant = (req: CreateTenantRequest) => post<Tenant>('/tenants', req);
export const updateTenant = (id: number, req: UpdateTenantRequest) => put<Tenant>(`/tenants/${id}`, req);

파일 보기

@ -0,0 +1,8 @@
import { get, post, put, del } from './apiClient';
import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../types/user';
export const getUsers = () => get<UserDetail[]>('/users');
export const getUser = (id: number) => get<UserDetail>(`/users/${id}`);
export const createUser = (req: CreateUserRequest) => post<UserDetail>('/users', req);
export const updateUser = (id: number, req: UpdateUserRequest) => put<UserDetail>(`/users/${id}`, req);
export const deactivateUser = (id: number) => del<void>(`/users/${id}`);

파일 보기

@ -0,0 +1,69 @@
export interface ServiceInfo {
serviceId: number;
serviceCode: string;
serviceName: string;
serviceUrl: string | null;
description: string | null;
healthCheckUrl: string | null;
healthCheckInterval: number;
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
healthCheckedAt: string | null;
healthResponseTime: number | null;
isActive: boolean;
createdAt: string;
}
export interface ServiceApi {
apiId: number;
serviceId: number;
apiPath: string;
apiMethod: string;
apiName: string;
description: string | null;
isActive: boolean;
createdAt: string;
}
export interface CreateServiceRequest {
serviceCode: string;
serviceName: string;
serviceUrl?: string;
description?: string;
healthCheckUrl?: string;
healthCheckInterval?: number;
}
export interface UpdateServiceRequest {
serviceName?: string;
serviceUrl?: string;
description?: string;
healthCheckUrl?: string;
healthCheckInterval?: number;
isActive?: boolean;
}
export interface CreateServiceApiRequest {
apiPath: string;
apiMethod: string;
apiName: string;
description?: string;
}
export interface HeartbeatStatus {
serviceId: number;
serviceCode: string;
serviceName: string;
healthStatus: string;
healthCheckedAt: string | null;
healthResponseTime: number | null;
}
export interface HealthHistory {
logId: number;
serviceId: number;
previousStatus: string | null;
currentStatus: string;
responseTime: number | null;
errorMessage: string | null;
checkedAt: string;
}

파일 보기

@ -0,0 +1,21 @@
export interface Tenant {
tenantId: number;
tenantCode: string;
tenantName: string;
description: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateTenantRequest {
tenantCode: string;
tenantName: string;
description?: string;
}
export interface UpdateTenantRequest {
tenantName: string;
description?: string;
isActive?: boolean;
}

파일 보기

@ -0,0 +1,30 @@
export interface UserDetail {
userId: number;
tenantId: number | null;
tenantName: string | null;
loginId: string;
userName: string;
email: string | null;
role: string;
isActive: boolean;
lastLoginAt: string | null;
createdAt: string;
}
export interface CreateUserRequest {
tenantId?: number;
loginId: string;
password: string;
userName: string;
email?: string;
role: string;
}
export interface UpdateUserRequest {
tenantId?: number;
userName?: string;
email?: string;
role?: string;
password?: string;
isActive?: boolean;
}

파일 보기

@ -13,6 +13,13 @@ public enum ErrorCode {
ACCESS_DENIED(403, "AUTH004", "접근 권한이 없습니다"),
USER_NOT_FOUND(404, "USER001", "사용자를 찾을 수 없습니다"),
USER_DISABLED(403, "USER002", "비활성화된 사용자입니다"),
USER_LOGIN_ID_DUPLICATE(409, "USER003", "이미 존재하는 로그인 ID입니다"),
TENANT_NOT_FOUND(404, "TENANT001", "테넌트를 찾을 수 없습니다"),
TENANT_CODE_DUPLICATE(409, "TENANT002", "이미 존재하는 테넌트 코드입니다"),
SERVICE_NOT_FOUND(404, "SVC001", "서비스를 찾을 수 없습니다"),
SERVICE_CODE_DUPLICATE(409, "SVC002", "이미 존재하는 서비스 코드입니다"),
SERVICE_API_NOT_FOUND(404, "SVC003", "서비스 API를 찾을 수 없습니다"),
HEALTH_CHECK_FAILED(500, "HC001", "헬스체크 실행에 실패했습니다"),
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
private final int status;

파일 보기

@ -4,6 +4,7 @@ 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.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@ -41,6 +42,16 @@ public class GlobalExceptionHandler {
.body(ApiResponse.error(message));
}
/**
* 접근 권한 없음 예외 처리
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
log.warn("Access denied: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("접근 권한이 없습니다"));
}
/**
* 처리되지 않은 예외 처리
*/

파일 보기

@ -5,6 +5,7 @@ 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.method.configuration.EnableMethodSecurity;
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;
@ -16,6 +17,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

파일 보기

@ -0,0 +1,26 @@
package com.gcsc.connection.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
@Configuration
public class WebClientConfig {
@Value("${app.heartbeat.timeout-seconds}")
private int timeoutSeconds;
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(timeoutSeconds));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}

파일 보기

@ -0,0 +1,57 @@
package com.gcsc.connection.monitoring.controller;
import com.gcsc.connection.common.dto.ApiResponse;
import com.gcsc.connection.monitoring.dto.HeartbeatStatusResponse;
import com.gcsc.connection.monitoring.dto.HealthHistoryResponse;
import com.gcsc.connection.monitoring.service.HeartbeatService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 헬스체크(하트비트) 모니터링 API
*/
@RestController
@RequestMapping("/api/heartbeat")
@RequiredArgsConstructor
public class HeartbeatController {
private final HeartbeatService heartbeatService;
/**
* 전체 서비스 헬스 상태 조회
*/
@GetMapping("/status")
public ResponseEntity<ApiResponse<List<HeartbeatStatusResponse>>> getAllServiceStatus() {
List<HeartbeatStatusResponse> statuses = heartbeatService.getAllServiceStatus();
return ResponseEntity.ok(ApiResponse.ok(statuses));
}
/**
* 서비스 헬스체크 이력 조회
*/
@GetMapping("/{serviceId}/history")
public ResponseEntity<ApiResponse<List<HealthHistoryResponse>>> getHealthHistory(
@PathVariable Long serviceId) {
List<HealthHistoryResponse> history = heartbeatService.getHealthHistory(serviceId);
return ResponseEntity.ok(ApiResponse.ok(history));
}
/**
* 수동 헬스체크 실행
*/
@PostMapping("/{serviceId}/check")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<HeartbeatStatusResponse>> checkService(
@PathVariable Long serviceId) {
HeartbeatStatusResponse result = heartbeatService.checkService(serviceId);
return ResponseEntity.ok(ApiResponse.ok(result));
}
}

파일 보기

@ -0,0 +1,28 @@
package com.gcsc.connection.monitoring.dto;
import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog;
import java.time.LocalDateTime;
public record HealthHistoryResponse(
Long logId,
Long serviceId,
String previousStatus,
String currentStatus,
Integer responseTime,
String errorMessage,
LocalDateTime checkedAt
) {
public static HealthHistoryResponse from(SnpServiceHealthLog log) {
return new HealthHistoryResponse(
log.getLogId(),
log.getService().getServiceId(),
log.getPreviousStatus(),
log.getCurrentStatus(),
log.getResponseTime(),
log.getErrorMessage(),
log.getCheckedAt()
);
}
}

파일 보기

@ -0,0 +1,26 @@
package com.gcsc.connection.monitoring.dto;
import com.gcsc.connection.service.entity.SnpService;
import java.time.LocalDateTime;
public record HeartbeatStatusResponse(
Long serviceId,
String serviceCode,
String serviceName,
String healthStatus,
LocalDateTime healthCheckedAt,
Integer healthResponseTime
) {
public static HeartbeatStatusResponse from(SnpService s) {
return new HeartbeatStatusResponse(
s.getServiceId(),
s.getServiceCode(),
s.getServiceName(),
s.getHealthStatus().name(),
s.getHealthCheckedAt(),
s.getHealthResponseTime()
);
}
}

파일 보기

@ -3,5 +3,9 @@ package com.gcsc.connection.monitoring.repository;
import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SnpServiceHealthLogRepository extends JpaRepository<SnpServiceHealthLog, Long> {
List<SnpServiceHealthLog> findByServiceServiceIdOrderByCheckedAtDesc(Long serviceId);
}

파일 보기

@ -0,0 +1,25 @@
package com.gcsc.connection.monitoring.scheduler;
import com.gcsc.connection.monitoring.service.HeartbeatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class HeartbeatScheduler {
private final HeartbeatService heartbeatService;
/**
* 주기적 헬스체크 스케줄러
*/
@Scheduled(fixedDelayString = "${app.heartbeat.default-interval-seconds}000")
public void scheduledHealthCheck() {
log.debug("스케줄 헬스체크 시작");
heartbeatService.checkAllActiveServices();
log.debug("스케줄 헬스체크 완료");
}
}

파일 보기

@ -0,0 +1,119 @@
package com.gcsc.connection.monitoring.service;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.monitoring.dto.HeartbeatStatusResponse;
import com.gcsc.connection.monitoring.dto.HealthHistoryResponse;
import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog;
import com.gcsc.connection.monitoring.repository.SnpServiceHealthLogRepository;
import com.gcsc.connection.service.entity.ServiceStatus;
import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.repository.SnpServiceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.LocalDateTime;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class HeartbeatService {
private final SnpServiceRepository snpServiceRepository;
private final SnpServiceHealthLogRepository snpServiceHealthLogRepository;
private final WebClient webClient;
/**
* 전체 서비스 헬스 상태 조회
*/
@Transactional(readOnly = true)
public List<HeartbeatStatusResponse> getAllServiceStatus() {
return snpServiceRepository.findAll().stream()
.map(HeartbeatStatusResponse::from)
.toList();
}
/**
* 서비스 헬스체크 이력 조회
*/
@Transactional(readOnly = true)
public List<HealthHistoryResponse> getHealthHistory(Long serviceId) {
snpServiceRepository.findById(serviceId)
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
return snpServiceHealthLogRepository.findByServiceServiceIdOrderByCheckedAtDesc(serviceId).stream()
.map(HealthHistoryResponse::from)
.toList();
}
/**
* 단일 서비스 헬스체크 실행
*/
@Transactional
public HeartbeatStatusResponse checkService(Long serviceId) {
SnpService service = snpServiceRepository.findById(serviceId)
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
performHealthCheck(service);
return HeartbeatStatusResponse.from(service);
}
/**
* 활성화된 모든 서비스 헬스체크 실행
*/
@Transactional
public void checkAllActiveServices() {
List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue();
activeServices.stream()
.filter(s -> s.getHealthCheckUrl() != null && !s.getHealthCheckUrl().isBlank())
.forEach(this::performHealthCheck);
}
/**
* 개별 서비스 헬스체크 수행
*/
private void performHealthCheck(SnpService service) {
ServiceStatus previousStatus = service.getHealthStatus();
ServiceStatus newStatus;
Integer responseTime = null;
String errorMessage = null;
long start = System.currentTimeMillis();
try {
webClient.get()
.uri(service.getHealthCheckUrl())
.retrieve()
.toBodilessEntity()
.block();
newStatus = ServiceStatus.UP;
responseTime = (int) (System.currentTimeMillis() - start);
} catch (Exception e) {
newStatus = ServiceStatus.DOWN;
responseTime = (int) (System.currentTimeMillis() - start);
errorMessage = e.getMessage();
log.warn("헬스체크 실패 - 서비스: {}, 오류: {}", service.getServiceCode(), e.getMessage());
}
if (previousStatus != newStatus) {
SnpServiceHealthLog healthLog = SnpServiceHealthLog.builder()
.service(service)
.previousStatus(previousStatus.name())
.currentStatus(newStatus.name())
.responseTime(responseTime)
.errorMessage(errorMessage)
.checkedAt(LocalDateTime.now())
.build();
snpServiceHealthLogRepository.save(healthLog);
log.info("서비스 상태 변경 - {}: {} -> {}", service.getServiceCode(), previousStatus, newStatus);
}
service.updateHealthStatus(newStatus, responseTime);
snpServiceRepository.save(service);
}
}

파일 보기

@ -0,0 +1,87 @@
package com.gcsc.connection.service.controller;
import com.gcsc.connection.common.dto.ApiResponse;
import com.gcsc.connection.service.dto.CreateServiceApiRequest;
import com.gcsc.connection.service.dto.CreateServiceRequest;
import com.gcsc.connection.service.dto.ServiceApiResponse;
import com.gcsc.connection.service.dto.ServiceResponse;
import com.gcsc.connection.service.dto.UpdateServiceRequest;
import com.gcsc.connection.service.service.ServiceManagementService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 서비스 관리 API
*/
@RestController
@RequestMapping("/api/services")
@RequiredArgsConstructor
public class ServiceController {
private final ServiceManagementService serviceManagementService;
/**
* 전체 서비스 목록 조회
*/
@GetMapping
public ResponseEntity<ApiResponse<List<ServiceResponse>>> getServices() {
List<ServiceResponse> services = serviceManagementService.getServices();
return ResponseEntity.ok(ApiResponse.ok(services));
}
/**
* 서비스 생성
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ServiceResponse>> createService(
@RequestBody @Valid CreateServiceRequest request) {
ServiceResponse service = serviceManagementService.createService(request);
return ResponseEntity.ok(ApiResponse.ok(service));
}
/**
* 서비스 수정
*/
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ServiceResponse>> updateService(
@PathVariable Long id,
@RequestBody @Valid UpdateServiceRequest request) {
ServiceResponse service = serviceManagementService.updateService(id, request);
return ResponseEntity.ok(ApiResponse.ok(service));
}
/**
* 서비스 API 목록 조회
*/
@GetMapping("/{id}/apis")
public ResponseEntity<ApiResponse<List<ServiceApiResponse>>> getServiceApis(
@PathVariable Long id) {
List<ServiceApiResponse> apis = serviceManagementService.getServiceApis(id);
return ResponseEntity.ok(ApiResponse.ok(apis));
}
/**
* 서비스 API 생성
*/
@PostMapping("/{id}/apis")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ServiceApiResponse>> createServiceApi(
@PathVariable Long id,
@RequestBody @Valid CreateServiceApiRequest request) {
ServiceApiResponse api = serviceManagementService.createServiceApi(id, request);
return ResponseEntity.ok(ApiResponse.ok(api));
}
}

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.connection.service.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateServiceApiRequest(
@NotBlank String apiPath,
@NotBlank String apiMethod,
@NotBlank String apiName,
String description
) {
}

파일 보기

@ -0,0 +1,13 @@
package com.gcsc.connection.service.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateServiceRequest(
@NotBlank String serviceCode,
@NotBlank String serviceName,
String serviceUrl,
String description,
String healthCheckUrl,
Integer healthCheckInterval
) {
}

파일 보기

@ -0,0 +1,32 @@
package com.gcsc.connection.service.dto;
import com.gcsc.connection.service.entity.SnpServiceApi;
import java.time.LocalDateTime;
public record ServiceApiResponse(
Long apiId,
Long serviceId,
String apiPath,
String apiMethod,
String apiName,
String description,
Boolean isActive,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static ServiceApiResponse from(SnpServiceApi a) {
return new ServiceApiResponse(
a.getApiId(),
a.getService().getServiceId(),
a.getApiPath(),
a.getApiMethod(),
a.getApiName(),
a.getDescription(),
a.getIsActive(),
a.getCreatedAt(),
a.getUpdatedAt()
);
}
}

파일 보기

@ -0,0 +1,40 @@
package com.gcsc.connection.service.dto;
import com.gcsc.connection.service.entity.SnpService;
import java.time.LocalDateTime;
public record ServiceResponse(
Long serviceId,
String serviceCode,
String serviceName,
String serviceUrl,
String description,
String healthCheckUrl,
Integer healthCheckInterval,
String healthStatus,
LocalDateTime healthCheckedAt,
Integer healthResponseTime,
Boolean isActive,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static ServiceResponse from(SnpService s) {
return new ServiceResponse(
s.getServiceId(),
s.getServiceCode(),
s.getServiceName(),
s.getServiceUrl(),
s.getDescription(),
s.getHealthCheckUrl(),
s.getHealthCheckInterval(),
s.getHealthStatus().name(),
s.getHealthCheckedAt(),
s.getHealthResponseTime(),
s.getIsActive(),
s.getCreatedAt(),
s.getUpdatedAt()
);
}
}

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.connection.service.dto;
public record UpdateServiceRequest(
String serviceName,
String serviceUrl,
String description,
String healthCheckUrl,
Integer healthCheckInterval,
Boolean isActive
) {
}

파일 보기

@ -70,4 +70,20 @@ public class SnpService extends BaseEntity {
this.healthStatus = healthStatus != null ? healthStatus : ServiceStatus.UNKNOWN;
this.isActive = isActive != null ? isActive : true;
}
public void update(String serviceName, String serviceUrl, String description,
String healthCheckUrl, Integer healthCheckInterval, Boolean isActive) {
if (serviceName != null) this.serviceName = serviceName;
if (serviceUrl != null) this.serviceUrl = serviceUrl;
if (description != null) this.description = description;
if (healthCheckUrl != null) this.healthCheckUrl = healthCheckUrl;
if (healthCheckInterval != null) this.healthCheckInterval = healthCheckInterval;
if (isActive != null) this.isActive = isActive;
}
public void updateHealthStatus(ServiceStatus status, Integer responseTime) {
this.healthStatus = status;
this.healthResponseTime = responseTime;
this.healthCheckedAt = LocalDateTime.now();
}
}

파일 보기

@ -56,4 +56,12 @@ public class SnpServiceApi extends BaseEntity {
this.description = description;
this.isActive = isActive != null ? isActive : true;
}
public void update(String apiPath, String apiMethod, String apiName, String description, Boolean isActive) {
if (apiPath != null) this.apiPath = apiPath;
if (apiMethod != null) this.apiMethod = apiMethod;
if (apiName != null) this.apiName = apiName;
if (description != null) this.description = description;
if (isActive != null) this.isActive = isActive;
}
}

파일 보기

@ -9,4 +9,6 @@ import java.util.List;
public interface SnpServiceApiRepository extends JpaRepository<SnpServiceApi, Long> {
List<SnpServiceApi> findByService(SnpService service);
List<SnpServiceApi> findByServiceServiceId(Long serviceId);
}

파일 보기

@ -3,9 +3,14 @@ package com.gcsc.connection.service.repository;
import com.gcsc.connection.service.entity.SnpService;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface SnpServiceRepository extends JpaRepository<SnpService, Long> {
Optional<SnpService> findByServiceCode(String serviceCode);
boolean existsByServiceCode(String serviceCode);
List<SnpService> findByIsActiveTrue();
}

파일 보기

@ -0,0 +1,112 @@
package com.gcsc.connection.service.service;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.service.dto.CreateServiceApiRequest;
import com.gcsc.connection.service.dto.CreateServiceRequest;
import com.gcsc.connection.service.dto.ServiceApiResponse;
import com.gcsc.connection.service.dto.ServiceResponse;
import com.gcsc.connection.service.dto.UpdateServiceRequest;
import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi;
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
import com.gcsc.connection.service.repository.SnpServiceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class ServiceManagementService {
private final SnpServiceRepository snpServiceRepository;
private final SnpServiceApiRepository snpServiceApiRepository;
/**
* 전체 서비스 목록 조회
*/
@Transactional(readOnly = true)
public List<ServiceResponse> getServices() {
return snpServiceRepository.findAll().stream()
.map(ServiceResponse::from)
.toList();
}
/**
* 서비스 생성
*/
@Transactional
public ServiceResponse createService(CreateServiceRequest request) {
if (snpServiceRepository.existsByServiceCode(request.serviceCode())) {
throw new BusinessException(ErrorCode.SERVICE_CODE_DUPLICATE);
}
SnpService service = SnpService.builder()
.serviceCode(request.serviceCode())
.serviceName(request.serviceName())
.serviceUrl(request.serviceUrl())
.description(request.description())
.healthCheckUrl(request.healthCheckUrl())
.healthCheckInterval(request.healthCheckInterval())
.build();
SnpService saved = snpServiceRepository.save(service);
log.info("서비스 생성 완료: {}", saved.getServiceCode());
return ServiceResponse.from(saved);
}
/**
* 서비스 수정
*/
@Transactional
public ServiceResponse updateService(Long id, UpdateServiceRequest request) {
SnpService service = snpServiceRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
service.update(
request.serviceName(), request.serviceUrl(), request.description(),
request.healthCheckUrl(), request.healthCheckInterval(), request.isActive()
);
log.info("서비스 수정 완료: {}", service.getServiceCode());
return ServiceResponse.from(service);
}
/**
* 서비스 API 목록 조회
*/
@Transactional(readOnly = true)
public List<ServiceApiResponse> getServiceApis(Long serviceId) {
snpServiceRepository.findById(serviceId)
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
return snpServiceApiRepository.findByServiceServiceId(serviceId).stream()
.map(ServiceApiResponse::from)
.toList();
}
/**
* 서비스 API 생성
*/
@Transactional
public ServiceApiResponse createServiceApi(Long serviceId, CreateServiceApiRequest request) {
SnpService service = snpServiceRepository.findById(serviceId)
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
SnpServiceApi api = SnpServiceApi.builder()
.service(service)
.apiPath(request.apiPath())
.apiMethod(request.apiMethod())
.apiName(request.apiName())
.description(request.description())
.build();
SnpServiceApi saved = snpServiceApiRepository.save(api);
log.info("서비스 API 생성 완료: {} {}", saved.getApiMethod(), saved.getApiPath());
return ServiceApiResponse.from(saved);
}
}

파일 보기

@ -0,0 +1,64 @@
package com.gcsc.connection.tenant.controller;
import com.gcsc.connection.common.dto.ApiResponse;
import com.gcsc.connection.tenant.dto.CreateTenantRequest;
import com.gcsc.connection.tenant.dto.TenantResponse;
import com.gcsc.connection.tenant.dto.UpdateTenantRequest;
import com.gcsc.connection.tenant.service.TenantService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 테넌트 관리 API
*/
@RestController
@RequestMapping("/api/tenants")
@RequiredArgsConstructor
public class TenantController {
private final TenantService tenantService;
/**
* 전체 테넌트 목록 조회
*/
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<List<TenantResponse>>> getTenants() {
List<TenantResponse> tenants = tenantService.getTenants();
return ResponseEntity.ok(ApiResponse.ok(tenants));
}
/**
* 테넌트 생성
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<TenantResponse>> createTenant(
@RequestBody @Valid CreateTenantRequest request) {
TenantResponse tenant = tenantService.createTenant(request);
return ResponseEntity.ok(ApiResponse.ok(tenant));
}
/**
* 테넌트 수정
*/
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<TenantResponse>> updateTenant(
@PathVariable Long id,
@RequestBody @Valid UpdateTenantRequest request) {
TenantResponse tenant = tenantService.updateTenant(id, request);
return ResponseEntity.ok(ApiResponse.ok(tenant));
}
}

파일 보기

@ -0,0 +1,10 @@
package com.gcsc.connection.tenant.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateTenantRequest(
@NotBlank String tenantCode,
@NotBlank String tenantName,
String description
) {
}

파일 보기

@ -0,0 +1,28 @@
package com.gcsc.connection.tenant.dto;
import com.gcsc.connection.tenant.entity.SnpTenant;
import java.time.LocalDateTime;
public record TenantResponse(
Long tenantId,
String tenantCode,
String tenantName,
String description,
Boolean isActive,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static TenantResponse from(SnpTenant t) {
return new TenantResponse(
t.getTenantId(),
t.getTenantCode(),
t.getTenantName(),
t.getDescription(),
t.getIsActive(),
t.getCreatedAt(),
t.getUpdatedAt()
);
}
}

파일 보기

@ -0,0 +1,10 @@
package com.gcsc.connection.tenant.dto;
import jakarta.validation.constraints.NotBlank;
public record UpdateTenantRequest(
@NotBlank String tenantName,
String description,
Boolean isActive
) {
}

파일 보기

@ -41,4 +41,10 @@ public class SnpTenant extends BaseEntity {
this.description = description;
this.isActive = isActive != null ? isActive : true;
}
public void update(String tenantName, String description, Boolean isActive) {
this.tenantName = tenantName;
this.description = description;
if (isActive != null) this.isActive = isActive;
}
}

파일 보기

@ -8,4 +8,6 @@ import java.util.Optional;
public interface SnpTenantRepository extends JpaRepository<SnpTenant, Long> {
Optional<SnpTenant> findByTenantCode(String tenantCode);
boolean existsByTenantCode(String tenantCode);
}

파일 보기

@ -0,0 +1,66 @@
package com.gcsc.connection.tenant.service;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.tenant.dto.CreateTenantRequest;
import com.gcsc.connection.tenant.dto.TenantResponse;
import com.gcsc.connection.tenant.dto.UpdateTenantRequest;
import com.gcsc.connection.tenant.entity.SnpTenant;
import com.gcsc.connection.tenant.repository.SnpTenantRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class TenantService {
private final SnpTenantRepository snpTenantRepository;
/**
* 전체 테넌트 목록 조회
*/
@Transactional(readOnly = true)
public List<TenantResponse> getTenants() {
return snpTenantRepository.findAll().stream()
.map(TenantResponse::from)
.toList();
}
/**
* 테넌트 생성
*/
@Transactional
public TenantResponse createTenant(CreateTenantRequest request) {
if (snpTenantRepository.existsByTenantCode(request.tenantCode())) {
throw new BusinessException(ErrorCode.TENANT_CODE_DUPLICATE);
}
SnpTenant tenant = SnpTenant.builder()
.tenantCode(request.tenantCode())
.tenantName(request.tenantName())
.description(request.description())
.build();
SnpTenant saved = snpTenantRepository.save(tenant);
log.info("테넌트 생성 완료: {}", saved.getTenantCode());
return TenantResponse.from(saved);
}
/**
* 테넌트 수정
*/
@Transactional
public TenantResponse updateTenant(Long id, UpdateTenantRequest request) {
SnpTenant tenant = snpTenantRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.TENANT_NOT_FOUND));
tenant.update(request.tenantName(), request.description(), request.isActive());
log.info("테넌트 수정 완료: {}", tenant.getTenantCode());
return TenantResponse.from(tenant);
}
}

파일 보기

@ -0,0 +1,107 @@
package com.gcsc.connection.user.controller;
import com.gcsc.connection.common.dto.ApiResponse;
import com.gcsc.connection.user.dto.CreateUserRequest;
import com.gcsc.connection.user.dto.UpdateUserRequest;
import com.gcsc.connection.user.dto.UserResponse;
import com.gcsc.connection.user.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 사용자 관리 API
*/
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 전체 사용자 목록 조회
*/
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<List<UserResponse>>> getUsers() {
List<UserResponse> users = userService.getUsers();
return ResponseEntity.ok(ApiResponse.ok(users));
}
/**
* 사용자 생성
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<UserResponse>> createUser(
@RequestBody @Valid CreateUserRequest request) {
UserResponse user = userService.createUser(request);
return ResponseEntity.ok(ApiResponse.ok(user));
}
/**
* 사용자 단건 조회 (ADMIN/MANAGER 또는 본인)
*/
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long id) {
String currentUserId = SecurityContextHolder.getContext().getAuthentication().getName();
if (!isAdminOrManager() && !currentUserId.equals(String.valueOf(id))) {
throw new AccessDeniedException("접근 권한이 없습니다");
}
UserResponse user = userService.getUser(id);
return ResponseEntity.ok(ApiResponse.ok(user));
}
/**
* 사용자 수정 (ADMIN은 모든 사용자, 본인은 자기 정보만)
*/
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<UserResponse>> updateUser(
@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
String currentUserId = SecurityContextHolder.getContext().getAuthentication().getName();
if (!isAdmin() && !currentUserId.equals(String.valueOf(id))) {
throw new AccessDeniedException("접근 권한이 없습니다");
}
UserResponse user = userService.updateUser(id, request);
return ResponseEntity.ok(ApiResponse.ok(user));
}
/**
* 사용자 비활성화 (소프트 삭제)
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<Void>> deactivateUser(@PathVariable Long id) {
userService.deactivateUser(id);
return ResponseEntity.ok(ApiResponse.ok(null, "사용자가 비활성화되었습니다"));
}
private boolean isAdmin() {
return SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
}
private boolean isAdminOrManager() {
return SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")
|| a.getAuthority().equals("ROLE_MANAGER"));
}
}

파일 보기

@ -0,0 +1,13 @@
package com.gcsc.connection.user.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateUserRequest(
Long tenantId,
@NotBlank String loginId,
@NotBlank String password,
@NotBlank String userName,
String email,
@NotBlank String role
) {
}

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.connection.user.dto;
public record UpdateUserRequest(
Long tenantId,
String userName,
String email,
String role,
String password,
Boolean isActive
) {
}

파일 보기

@ -0,0 +1,34 @@
package com.gcsc.connection.user.dto;
import com.gcsc.connection.user.entity.SnpUser;
import java.time.LocalDateTime;
public record UserResponse(
Long userId,
Long tenantId,
String tenantName,
String loginId,
String userName,
String email,
String role,
Boolean isActive,
LocalDateTime lastLoginAt,
LocalDateTime createdAt
) {
public static UserResponse from(SnpUser u) {
return new UserResponse(
u.getUserId(),
u.getTenant() != null ? u.getTenant().getTenantId() : null,
u.getTenant() != null ? u.getTenant().getTenantName() : null,
u.getLoginId(),
u.getUserName(),
u.getEmail(),
u.getRole().name(),
u.getIsActive(),
u.getLastLoginAt(),
u.getCreatedAt()
);
}
}

파일 보기

@ -71,4 +71,20 @@ public class SnpUser extends BaseEntity {
public void updateLastLoginAt() {
this.lastLoginAt = LocalDateTime.now();
}
public void update(SnpTenant tenant, String userName, String email, UserRole role, Boolean isActive) {
if (tenant != null) this.tenant = tenant;
if (userName != null) this.userName = userName;
if (email != null) this.email = email;
if (role != null) this.role = role;
if (isActive != null) this.isActive = isActive;
}
public void updatePassword(String passwordHash) {
this.passwordHash = passwordHash;
}
public void deactivate() {
this.isActive = false;
}
}

파일 보기

@ -0,0 +1,114 @@
package com.gcsc.connection.user.service;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.tenant.entity.SnpTenant;
import com.gcsc.connection.tenant.repository.SnpTenantRepository;
import com.gcsc.connection.user.dto.CreateUserRequest;
import com.gcsc.connection.user.dto.UpdateUserRequest;
import com.gcsc.connection.user.dto.UserResponse;
import com.gcsc.connection.user.entity.SnpUser;
import com.gcsc.connection.user.entity.UserRole;
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;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class UserService {
private final SnpUserRepository snpUserRepository;
private final SnpTenantRepository snpTenantRepository;
private final PasswordEncoder passwordEncoder;
/**
* 전체 사용자 목록 조회
*/
@Transactional(readOnly = true)
public List<UserResponse> getUsers() {
return snpUserRepository.findAll().stream()
.map(UserResponse::from)
.toList();
}
/**
* 사용자 단건 조회
*/
@Transactional(readOnly = true)
public UserResponse getUser(Long id) {
SnpUser user = snpUserRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
return UserResponse.from(user);
}
/**
* 사용자 생성
*/
@Transactional
public UserResponse createUser(CreateUserRequest request) {
if (snpUserRepository.existsByLoginId(request.loginId())) {
throw new BusinessException(ErrorCode.USER_LOGIN_ID_DUPLICATE);
}
SnpTenant tenant = null;
if (request.tenantId() != null) {
tenant = snpTenantRepository.findById(request.tenantId())
.orElseThrow(() -> new BusinessException(ErrorCode.TENANT_NOT_FOUND));
}
SnpUser user = SnpUser.builder()
.tenant(tenant)
.loginId(request.loginId())
.passwordHash(passwordEncoder.encode(request.password()))
.userName(request.userName())
.email(request.email())
.role(UserRole.valueOf(request.role()))
.build();
SnpUser saved = snpUserRepository.save(user);
log.info("사용자 생성 완료: {}", saved.getLoginId());
return UserResponse.from(saved);
}
/**
* 사용자 수정
*/
@Transactional
public UserResponse updateUser(Long id, UpdateUserRequest request) {
SnpUser user = snpUserRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
SnpTenant tenant = null;
if (request.tenantId() != null) {
tenant = snpTenantRepository.findById(request.tenantId())
.orElseThrow(() -> new BusinessException(ErrorCode.TENANT_NOT_FOUND));
}
UserRole role = request.role() != null ? UserRole.valueOf(request.role()) : null;
user.update(tenant, request.userName(), request.email(), role, request.isActive());
if (request.password() != null && !request.password().isBlank()) {
user.updatePassword(passwordEncoder.encode(request.password()));
}
log.info("사용자 수정 완료: {}", user.getLoginId());
return UserResponse.from(user);
}
/**
* 사용자 비활성화 (소프트 삭제)
*/
@Transactional
public void deactivateUser(Long id) {
SnpUser user = snpUserRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
user.deactivate();
log.info("사용자 비활성화 완료: {}", user.getLoginId());
}
}