From efa7a7bd0720e26314557b0aed42a82c5ecfbd8b Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Tue, 7 Apr 2026 17:04:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase2):=20=ED=95=B5=EC=8B=AC=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20CRUD=20+=20=ED=95=98=ED=8A=B8?= =?UTF-8?q?=EB=B9=84=ED=8A=B8=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - 테넌트 CRUD API (GET/POST/PUT /api/tenants) - 사용자 CRUD API (GET/POST/PUT/DELETE /api/users, 역할 기반 접근 제어) - 서비스 등록/관리 API (GET/POST/PUT /api/services, /api/services/{id}/apis) - 하트비트 스케줄러 (30초 간격 폴링, WebClient 헬스체크) - 헬스체크 상태 조회/이력 API (GET/POST /api/heartbeat) - @EnableMethodSecurity + @PreAuthorize 역할 기반 접근 제어 - WebClientConfig, ErrorCode 7개 추가 프론트엔드: - 테넌트 관리 페이지 (CRUD 테이블 + 모달) - 사용자 관리 페이지 (역할 드롭다운, 테넌트 선택) - 서비스 관리 페이지 (헬스 배지, API 목록 탭) - API 서비스 모듈 4개 (tenant, user, service, heartbeat) Closes #7 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/admin/ServicesPage.tsx | 569 +++++++++++++++++- frontend/src/pages/admin/TenantsPage.tsx | 250 +++++++- frontend/src/pages/admin/UsersPage.tsx | 346 ++++++++++- frontend/src/services/heartbeatService.ts | 6 + frontend/src/services/serviceService.ts | 15 + frontend/src/services/tenantService.ts | 6 + frontend/src/services/userService.ts | 8 + frontend/src/types/service.ts | 69 +++ frontend/src/types/tenant.ts | 21 + frontend/src/types/user.ts | 30 + .../common/exception/ErrorCode.java | 7 + .../exception/GlobalExceptionHandler.java | 11 + .../connection/config/SecurityConfig.java | 2 + .../connection/config/WebClientConfig.java | 26 + .../controller/HeartbeatController.java | 57 ++ .../monitoring/dto/HealthHistoryResponse.java | 28 + .../dto/HeartbeatStatusResponse.java | 26 + .../SnpServiceHealthLogRepository.java | 4 + .../scheduler/HeartbeatScheduler.java | 25 + .../monitoring/service/HeartbeatService.java | 119 ++++ .../service/controller/ServiceController.java | 87 +++ .../service/dto/CreateServiceApiRequest.java | 11 + .../service/dto/CreateServiceRequest.java | 13 + .../service/dto/ServiceApiResponse.java | 32 + .../service/dto/ServiceResponse.java | 40 ++ .../service/dto/UpdateServiceRequest.java | 11 + .../connection/service/entity/SnpService.java | 16 + .../service/entity/SnpServiceApi.java | 8 + .../repository/SnpServiceApiRepository.java | 2 + .../repository/SnpServiceRepository.java | 5 + .../service/ServiceManagementService.java | 112 ++++ .../tenant/controller/TenantController.java | 64 ++ .../tenant/dto/CreateTenantRequest.java | 10 + .../connection/tenant/dto/TenantResponse.java | 28 + .../tenant/dto/UpdateTenantRequest.java | 10 + .../connection/tenant/entity/SnpTenant.java | 6 + .../repository/SnpTenantRepository.java | 2 + .../tenant/service/TenantService.java | 66 ++ .../user/controller/UserController.java | 107 ++++ .../user/dto/CreateUserRequest.java | 13 + .../user/dto/UpdateUserRequest.java | 11 + .../connection/user/dto/UserResponse.java | 34 ++ .../gcsc/connection/user/entity/SnpUser.java | 16 + .../connection/user/service/UserService.java | 114 ++++ 44 files changed, 2437 insertions(+), 6 deletions(-) create mode 100644 frontend/src/services/heartbeatService.ts create mode 100644 frontend/src/services/serviceService.ts create mode 100644 frontend/src/services/tenantService.ts create mode 100644 frontend/src/services/userService.ts create mode 100644 frontend/src/types/service.ts create mode 100644 frontend/src/types/tenant.ts create mode 100644 frontend/src/types/user.ts create mode 100644 src/main/java/com/gcsc/connection/config/WebClientConfig.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/controller/HeartbeatController.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/HealthHistoryResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/HeartbeatStatusResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/scheduler/HeartbeatScheduler.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/service/HeartbeatService.java create mode 100644 src/main/java/com/gcsc/connection/service/controller/ServiceController.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/CreateServiceApiRequest.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/CreateServiceRequest.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/ServiceApiResponse.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/ServiceResponse.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/UpdateServiceRequest.java create mode 100644 src/main/java/com/gcsc/connection/service/service/ServiceManagementService.java create mode 100644 src/main/java/com/gcsc/connection/tenant/controller/TenantController.java create mode 100644 src/main/java/com/gcsc/connection/tenant/dto/CreateTenantRequest.java create mode 100644 src/main/java/com/gcsc/connection/tenant/dto/TenantResponse.java create mode 100644 src/main/java/com/gcsc/connection/tenant/dto/UpdateTenantRequest.java create mode 100644 src/main/java/com/gcsc/connection/tenant/service/TenantService.java create mode 100644 src/main/java/com/gcsc/connection/user/controller/UserController.java create mode 100644 src/main/java/com/gcsc/connection/user/dto/CreateUserRequest.java create mode 100644 src/main/java/com/gcsc/connection/user/dto/UpdateUserRequest.java create mode 100644 src/main/java/com/gcsc/connection/user/dto/UserResponse.java create mode 100644 src/main/java/com/gcsc/connection/user/service/UserService.java diff --git a/frontend/src/pages/admin/ServicesPage.tsx b/frontend/src/pages/admin/ServicesPage.tsx index 49de803..256fd2f 100644 --- a/frontend/src/pages/admin/ServicesPage.tsx +++ b/frontend/src/pages/admin/ServicesPage.tsx @@ -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 = { + 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 = { + 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([]); + const [selectedService, setSelectedService] = useState(null); + const [serviceApis, setServiceApis] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [isServiceModalOpen, setIsServiceModalOpen] = useState(false); + const [editingService, setEditingService] = useState(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
로딩 중...
; + } + return (
-

Services

-

Coming soon

+
+

Services

+ +
+ + {error && !isServiceModalOpen && !isApiModalOpen && ( +
{error}
+ )} + +
+ + + + + + + + + + + + + + + {services.map((service) => { + const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN; + const isSelected = selectedService?.serviceId === service.serviceId; + return ( + handleSelectService(service)} + className={`cursor-pointer ${ + isSelected ? 'bg-blue-50' : 'hover:bg-gray-50' + }`} + > + + + + + + + + + + ); + })} + {services.length === 0 && ( + + + + )} + +
CodeNameURLHealth StatusResponse TimeLast CheckedActiveActions
{service.serviceCode}{service.serviceName} + {service.serviceUrl || '-'} + + + + {service.healthStatus} + + + {service.healthResponseTime != null + ? `${service.healthResponseTime}ms` + : '-'} + + {formatRelativeTime(service.healthCheckedAt)} + + + {service.isActive ? 'Active' : 'Inactive'} + + + +
+ 등록된 서비스가 없습니다. +
+
+ + {selectedService && ( +
+
+

+ APIs for {selectedService.serviceName} +

+ +
+
+ + + + + + + + + + + + {serviceApis.map((api) => ( + + + + + + + + ))} + {serviceApis.length === 0 && ( + + + + )} + +
MethodPathNameDescriptionActive
+ + {api.apiMethod} + + {api.apiPath}{api.apiName}{api.description || '-'} + + {api.isActive ? 'Active' : 'Inactive'} + +
+ 등록된 API가 없습니다. +
+
+
+ )} + + {isServiceModalOpen && ( +
+
+
+

+ {editingService ? '서비스 수정' : '서비스 생성'} +

+
+
+
+ {error && ( +
{error}
+ )} +
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + setServiceUrl(e.target.value)} + className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+
+ +