generated from gc/template-java-maven
Merge pull request 'feat(phase2): 핵심 관리 기능 CRUD + 하트비트 스케줄러' (#13) from feature/ISSUE-7-phase2-crud-heartbeat into develop
This commit is contained in:
커밋
d4ccb1f4c6
@ -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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
6
frontend/src/services/heartbeatService.ts
Normal file
6
frontend/src/services/heartbeatService.ts
Normal file
@ -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`);
|
||||
15
frontend/src/services/serviceService.ts
Normal file
15
frontend/src/services/serviceService.ts
Normal file
@ -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);
|
||||
6
frontend/src/services/tenantService.ts
Normal file
6
frontend/src/services/tenantService.ts
Normal file
@ -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);
|
||||
8
frontend/src/services/userService.ts
Normal file
8
frontend/src/services/userService.ts
Normal file
@ -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}`);
|
||||
69
frontend/src/types/service.ts
Normal file
69
frontend/src/types/service.ts
Normal file
@ -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;
|
||||
}
|
||||
21
frontend/src/types/tenant.ts
Normal file
21
frontend/src/types/tenant.ts
Normal file
@ -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;
|
||||
}
|
||||
30
frontend/src/types/user.ts
Normal file
30
frontend/src/types/user.ts
Normal file
@ -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
|
||||
) {
|
||||
}
|
||||
34
src/main/java/com/gcsc/connection/user/dto/UserResponse.java
Normal file
34
src/main/java/com/gcsc/connection/user/dto/UserResponse.java
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
114
src/main/java/com/gcsc/connection/user/service/UserService.java
Normal file
114
src/main/java/com/gcsc/connection/user/service/UserService.java
Normal file
@ -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());
|
||||
}
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user