feat(domain): 도메인 관리 기능 및 API HUB 사이드바 개선

- SnpApiDomain 엔티티/레포/컨트롤러 (CRUD /api/domains)
- Admin 도메인 관리 페이지 (DomainsPage) - SVG 아이콘 미리보기
- API HUB 사이드바: 서비스 기반 3단 → 도메인 기반 2단 플랫 메뉴
- DB 아이콘/정렬순서 반영 (viewBox 24x24, 다중 path 지원)
- 카탈로그 DomainGroup에 iconPath/sortOrder 추가
- API 관리 도메인 입력을 셀렉트박스로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-04-14 13:59:05 +09:00
부모 dfee04f703
커밋 17d870c06a
14개의 변경된 파일770개의 추가작업 그리고 122개의 파일을 삭제

파일 보기

@ -11,6 +11,10 @@ import MyKeysPage from './pages/apikeys/MyKeysPage';
import KeyRequestPage from './pages/apikeys/KeyRequestPage'; import KeyRequestPage from './pages/apikeys/KeyRequestPage';
import KeyAdminPage from './pages/apikeys/KeyAdminPage'; import KeyAdminPage from './pages/apikeys/KeyAdminPage';
import ServicesPage from './pages/admin/ServicesPage'; import ServicesPage from './pages/admin/ServicesPage';
import DomainsPage from './pages/admin/DomainsPage';
import ApisPage from './pages/admin/ApisPage';
import ApiEditPage from './pages/admin/ApiEditPage';
import SampleCodePage from './pages/admin/SampleCodePage';
import UsersPage from './pages/admin/UsersPage'; import UsersPage from './pages/admin/UsersPage';
import TenantsPage from './pages/admin/TenantsPage'; import TenantsPage from './pages/admin/TenantsPage';
import ServiceStatsPage from './pages/statistics/ServiceStatsPage'; import ServiceStatsPage from './pages/statistics/ServiceStatsPage';
@ -49,6 +53,10 @@ const App = () => {
<Route path="/apikeys/request" element={<KeyRequestPage />} /> <Route path="/apikeys/request" element={<KeyRequestPage />} />
<Route path="/apikeys/admin" element={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></RoleGuard>} /> <Route path="/apikeys/admin" element={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></RoleGuard>} />
<Route path="/admin/services" element={<RoleGuard allowedRoles={['ADMIN']}><ServicesPage /></RoleGuard>} /> <Route path="/admin/services" element={<RoleGuard allowedRoles={['ADMIN']}><ServicesPage /></RoleGuard>} />
<Route path="/admin/domains" element={<RoleGuard allowedRoles={['ADMIN']}><DomainsPage /></RoleGuard>} />
<Route path="/admin/apis" element={<RoleGuard allowedRoles={['ADMIN']}><ApisPage /></RoleGuard>} />
<Route path="/admin/apis/:serviceId/:apiId" element={<RoleGuard allowedRoles={['ADMIN']}><ApiEditPage /></RoleGuard>} />
<Route path="/admin/sample-code" element={<RoleGuard allowedRoles={['ADMIN']}><SampleCodePage /></RoleGuard>} />
<Route path="/admin/users" element={<RoleGuard allowedRoles={['ADMIN']}><UsersPage /></RoleGuard>} /> <Route path="/admin/users" element={<RoleGuard allowedRoles={['ADMIN']}><UsersPage /></RoleGuard>} />
<Route path="/admin/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} /> <Route path="/admin/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
@ -7,22 +7,28 @@ import type { ServiceCatalog } from '../types/apihub';
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const; const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
const METHOD_BADGE_CLASS: Record<string, string> = { const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', /** iconPath 문자열에서 SVG path d 값 배열을 추출 */
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', const parseIconPaths = (iconPath: string | null): string[] => {
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', if (!iconPath) return DEFAULT_ICON_PATHS;
// <path d="..."/> 형태에서 d 값 추출
const pathRegex = /d="([^"]+)"/g;
const matches: string[] = [];
let m;
while ((m = pathRegex.exec(iconPath)) !== null) {
matches.push(m[1]);
}
// d 태그가 없으면 단일 path d 값으로 간주
return matches.length > 0 ? matches : [iconPath];
}; };
const getMethodBadgeClass = (method: string): string => interface FlatDomainGroup {
METHOD_BADGE_CLASS[method.toUpperCase()] ?? domain: string;
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; iconPath: string | null;
sortOrder: number;
const HEALTH_DOT_CLASS: Record<ServiceCatalog['healthStatus'], string> = { apis: { serviceId: number; apiId: number; apiName: string; apiPath: string; apiMethod: string }[];
UP: 'bg-green-500', }
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400',
};
const ApiHubLayout = () => { const ApiHubLayout = () => {
const { user, setRole } = useAuth(); const { user, setRole } = useAuth();
@ -32,37 +38,68 @@ const ApiHubLayout = () => {
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]); const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [openServices, setOpenServices] = useState<Record<number, boolean>>({});
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({}); const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => { useEffect(() => {
getCatalog() getCatalog()
.then((res) => { .then((res) => {
const items = res.data ?? []; const items = res.data ?? [];
setCatalog(items); setCatalog(items);
// Open all service groups and domain groups by default
const serviceState: Record<number, boolean> = {};
const domainState: Record<string, boolean> = {};
items.forEach((svc) => {
serviceState[svc.serviceId] = true;
svc.domains.forEach((dg) => {
domainState[`${svc.serviceId}:${dg.domain}`] = true;
});
});
setOpenServices(serviceState);
setOpenDomains(domainState);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const toggleService = (serviceId: number) => {
setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] }));
};
const toggleDomain = (key: string) => { const toggleDomain = (key: string) => {
setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] })); setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] }));
}; };
// 서비스 계층을 제거하고 도메인 기준으로 플랫하게 재그룹핑
const domainGroups = useMemo<FlatDomainGroup[]>(() => {
const map = new Map<string, { apis: FlatDomainGroup['apis']; iconPath: string | null; sortOrder: number }>();
for (const svc of catalog) {
for (const dg of svc.domains) {
const key = dg.domain.toUpperCase();
const existing = map.get(key);
const apis = existing?.apis ?? [];
apis.push(
...dg.apis.map((api) => ({
serviceId: svc.serviceId,
apiId: api.apiId,
apiName: api.apiName,
apiPath: api.apiPath,
apiMethod: api.apiMethod,
})),
);
// 첫 번째로 발견된 iconPath/sortOrder 사용
const iconPath = existing?.iconPath !== undefined ? existing.iconPath : (dg.iconPath ?? null);
const sortOrder = existing?.sortOrder !== undefined ? existing.sortOrder : (dg.sortOrder ?? Number.MAX_SAFE_INTEGER);
map.set(key, { apis, iconPath, sortOrder });
}
}
return Array.from(map.entries())
.map(([domain, { apis, iconPath, sortOrder }]) => ({ domain, iconPath, sortOrder, apis }))
.sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain));
}, [catalog]);
const filteredDomainGroups = useMemo(() => {
if (!searchQuery.trim()) return domainGroups;
const q = searchQuery.trim().toLowerCase();
return domainGroups
.map((dg) => {
const domainMatch = dg.domain.toLowerCase().includes(q);
const filteredApis = dg.apis.filter(
(api) => api.apiName.toLowerCase().includes(q) || api.apiPath.toLowerCase().includes(q),
);
if (domainMatch) return dg;
if (filteredApis.length > 0) return { ...dg, apis: filteredApis };
return null;
})
.filter((dg): dg is NonNullable<typeof dg> => dg !== null);
}, [domainGroups, searchQuery]);
const isSearching = searchQuery.trim().length > 0;
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Sidebar */} {/* Sidebar */}
@ -98,8 +135,39 @@ const ApiHubLayout = () => {
</div> </div>
</div> </div>
{/* Search */}
<div className="flex-shrink-0 px-3 pt-3 pb-1">
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="API 검색..."
className="w-full bg-gray-800 border border-gray-700 text-gray-200 placeholder-gray-500 rounded-lg pl-8 pr-8 py-1.5 text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500 focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Navigation tree */} {/* Navigation tree */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1"> <nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<svg <svg
@ -122,95 +190,64 @@ const ApiHubLayout = () => {
/> />
</svg> </svg>
</div> </div>
) : filteredDomainGroups.length === 0 ? (
<p className="text-xs text-gray-500 text-center py-6"> </p>
) : ( ) : (
catalog.map((service) => { filteredDomainGroups.map((dg) => {
const serviceOpen = openServices[service.serviceId] ?? false; const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
const healthDot = HEALTH_DOT_CLASS[service.healthStatus];
return (
<div key={service.serviceId} className="mb-1">
{/* Service group header */}
<div className="flex items-center gap-1">
<button
onClick={() => navigate(`/api-hub/services/${service.serviceId}`)}
className="flex flex-1 min-w-0 items-center gap-2 rounded-l-lg px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors text-left"
>
<span
className={`h-2 w-2 flex-shrink-0 rounded-full ${healthDot}`}
title={service.healthStatus}
/>
<span className="truncate">{service.serviceName}</span>
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
{service.apiCount}
</span>
</button>
<button
onClick={() => toggleService(service.serviceId)}
className="flex-shrink-0 rounded-r-lg p-2 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
aria-label={serviceOpen ? 'Collapse' : 'Expand'}
>
<svg
className={`h-3.5 w-3.5 transition-transform ${serviceOpen ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Domain groups */}
{serviceOpen && (
<div className="ml-3 mt-0.5 space-y-0.5">
{service.domains.map((dg) => {
const domainKey = `${service.serviceId}:${dg.domain}`;
const domainOpen = openDomains[domainKey] ?? false;
return ( return (
<div key={dg.domain}> <div key={dg.domain}>
{/* Domain header */} {/* Domain header */}
<button <button
onClick={() => toggleDomain(domainKey)} onClick={() => toggleDomain(dg.domain)}
className="flex w-full items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors"
> >
<svg <svg
className={`h-3 w-3 flex-shrink-0 transition-transform ${domainOpen ? 'rotate-90' : ''}`} className="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{parseIconPaths(dg.iconPath).map((d, i) => (
<path key={i} d={d} />
))}
</svg>
<span className="truncate tracking-wider">{dg.domain}</span>
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
{dg.apis.length}
</span>
<svg
className={`h-3 w-3 flex-shrink-0 text-gray-500 transition-transform ${domainOpen ? 'rotate-90' : ''}`}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<span className="truncate uppercase tracking-wider">{dg.domain}</span>
<span className="ml-auto flex-shrink-0 text-gray-500">
{dg.apis.length}
</span>
</button> </button>
{/* API items */} {/* API items */}
{domainOpen && ( {domainOpen && (
<div className="ml-3 mt-0.5 space-y-0.5"> <div className="ml-4 mt-0.5 space-y-0.5">
{dg.apis.map((api) => { {dg.apis.map((api) => {
const apiPath = `/api-hub/services/${service.serviceId}/apis/${api.apiId}`; const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
const isActive = location.pathname === apiPath; const isActive = location.pathname === apiPath;
return ( return (
<NavLink <NavLink
key={api.apiId} key={`${api.serviceId}-${api.apiId}`}
to={apiPath} to={apiPath}
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition-colors ${ className={`block rounded-lg px-2.5 py-1.5 text-xs truncate transition-colors ${
isActive isActive
? 'bg-gray-700 text-white' ? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`} }`}
> >
<span {api.apiName}
className={`flex-shrink-0 rounded px-1 py-0.5 text-[10px] font-bold leading-none ${getMethodBadgeClass(api.apiMethod)}`}
>
{api.apiMethod.toUpperCase()}
</span>
<span className="truncate">{api.apiName}</span>
</NavLink> </NavLink>
); );
})} })}
@ -218,11 +255,6 @@ const ApiHubLayout = () => {
)} )}
</div> </div>
); );
})}
</div>
)}
</div>
);
}) })
)} )}
</nav> </nav>

파일 보기

@ -40,6 +40,9 @@ const navGroups: NavGroup[] = [
adminOnly: true, adminOnly: true,
items: [ items: [
{ label: 'Services', path: '/admin/services' }, { label: 'Services', path: '/admin/services' },
{ label: 'Domains', path: '/admin/domains' },
{ label: 'API 관리', path: '/admin/apis' },
{ label: '공통 샘플 코드', path: '/admin/sample-code' },
{ label: 'Users', path: '/admin/users' }, { label: 'Users', path: '/admin/users' },
{ label: 'Tenants', path: '/admin/tenants' }, { label: 'Tenants', path: '/admin/tenants' },
], ],

파일 보기

@ -0,0 +1,302 @@
import { useState, useEffect } from 'react';
import type { ApiDomainInfo } from '../../types/apihub';
import type { CreateDomainRequest, UpdateDomainRequest } from '../../services/serviceService';
import { getDomains, createDomain, updateDomain, deleteDomain } from '../../services/serviceService';
const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
const parseIconPaths = (iconPath: string | null): string[] => {
if (!iconPath) return DEFAULT_ICON_PATHS;
const pathRegex = /d="([^"]+)"/g;
const matches: string[] = [];
let m;
while ((m = pathRegex.exec(iconPath)) !== null) {
matches.push(m[1]);
}
return matches.length > 0 ? matches : [iconPath];
};
const DomainsPage = () => {
const [domains, setDomains] = useState<ApiDomainInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingDomain, setEditingDomain] = useState<ApiDomainInfo | null>(null);
const [domainName, setDomainName] = useState('');
const [iconPath, setIconPath] = useState('');
const [sortOrder, setSortOrder] = useState(0);
const fetchDomains = async () => {
try {
setLoading(true);
const res = await getDomains();
if (res.success && res.data) {
setDomains(res.data);
} else {
setError(res.message || '도메인 목록을 불러오는데 실패했습니다.');
}
} catch {
setError('도메인 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDomains();
}, []);
const handleOpenCreate = () => {
setEditingDomain(null);
setDomainName('');
setIconPath('');
setSortOrder(domains.length > 0 ? Math.max(...domains.map((d) => d.sortOrder)) + 1 : 0);
setIsModalOpen(true);
};
const handleOpenEdit = (domain: ApiDomainInfo) => {
setEditingDomain(domain);
setDomainName(domain.domainName);
setIconPath(domain.iconPath ?? '');
setSortOrder(domain.sortOrder);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingDomain(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingDomain) {
const req: UpdateDomainRequest = {
domainName,
iconPath: iconPath || null,
sortOrder,
};
const res = await updateDomain(editingDomain.domainId, req);
if (!res.success) {
setError(res.message || '도메인 수정에 실패했습니다.');
return;
}
} else {
const req: CreateDomainRequest = {
domainName,
iconPath: iconPath || null,
sortOrder,
};
const res = await createDomain(req);
if (!res.success) {
setError(res.message || '도메인 생성에 실패했습니다.');
return;
}
}
handleCloseModal();
await fetchDomains();
} catch {
setError(editingDomain ? '도메인 수정에 실패했습니다.' : '도메인 생성에 실패했습니다.');
}
};
const handleDelete = async (domain: ApiDomainInfo) => {
if (!window.confirm(`'${domain.domainName}' 도메인을 삭제하시겠습니까?`)) return;
try {
const res = await deleteDomain(domain.domainId);
if (!res.success) {
setError(res.message || '도메인 삭제에 실패했습니다.');
return;
}
await fetchDomains();
} catch {
setError('도메인 삭제에 실패했습니다.');
}
};
const previewPath = iconPath.trim() || null;
if (loading) {
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400"> ...</div>;
}
return (
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Domains</h1>
<button
onClick={handleOpenCreate}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</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 dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">#</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{domains.map((domain, index) => (
<tr key={domain.domainId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{index + 1}</td>
<td className="px-4 py-3">
<svg
className="h-5 w-5 text-gray-500 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{parseIconPaths(domain.iconPath).map((d, i) => (
<path key={i} d={d} />
))}
</svg>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{domain.domainName}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{domain.sortOrder}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenEdit(domain)}
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
>
</button>
<button
onClick={() => handleDelete(domain)}
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
>
</button>
</div>
</td>
</tr>
))}
{domains.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
.
</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 dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingDomain ? '도메인 수정' : '도메인 추가'}
</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 dark:text-gray-300 mb-1">
</label>
<input
type="text"
value={domainName}
onChange={(e) => setDomainName(e.target.value)}
required
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="flex items-center justify-between text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span>SVG Path</span>
<span className="text-xs font-normal text-gray-400 dark:text-gray-500">
:
<a href="https://heroicons.com" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Heroicons</a>
<a href="https://lucide.dev" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Lucide</a>
</span>
</label>
<textarea
value={iconPath}
onChange={(e) => setIconPath(e.target.value)}
rows={3}
placeholder="M4 6a2 2 0 012-2h8..."
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg">
<svg
className="h-8 w-8 text-gray-600 dark:text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{parseIconPaths(previewPath).map((d, i) => (
<path key={i} d={d} />
))}
</svg>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
min={0}
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button
type="button"
onClick={handleCloseModal}
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 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>
);
};
export default DomainsPage;

파일 보기

@ -1,8 +1,19 @@
export interface DomainGroup { export interface DomainGroup {
domain: string; domain: string;
iconPath: string | null;
sortOrder: number;
apis: ServiceApiItem[]; apis: ServiceApiItem[];
} }
export interface ApiDomainInfo {
domainId: number;
domainName: string;
iconPath: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface ServiceApiItem { export interface ServiceApiItem {
apiId: number; apiId: number;
serviceId: number; serviceId: number;
@ -22,6 +33,7 @@ export interface ServiceCatalog {
serviceCode: string; serviceCode: string;
serviceName: string; serviceName: string;
description: string | null; description: string | null;
serviceUrl: string | null;
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN'; healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
apiCount: number; apiCount: number;
domains: DomainGroup[]; domains: DomainGroup[];

55
scripts/init-domains.sh Normal file
파일 보기

@ -0,0 +1,55 @@
#!/bin/bash
# 도메인 초기 데이터 생성 스크립트
# 사용법: bash scripts/init-domains.sh
BASE_URL="http://localhost:8042/snp-connection/api/domains"
echo "=== 도메인 초기 데이터 생성 ==="
create_domain() {
local name="$1"
local icon="$2"
local order="$3"
echo -n " $name (sortOrder=$order) ... "
curl -s -X POST "$BASE_URL" \
-H "Content-Type: application/json" \
-d "{\"domainName\":\"$name\",\"iconPath\":\"$icon\",\"sortOrder\":$order}" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print('OK' if d.get('success') else d.get('message','FAIL'))" 2>/dev/null || echo "ERROR"
}
create_domain "SCREENING" \
"M10 1l2.39 4.843 5.346.777-3.868 3.77.913 5.323L10 13.347l-4.781 2.366.913-5.323L2.264 6.62l5.346-.777L10 1z" \
1
create_domain "AIS" \
"M10 2a6 6 0 00-6 6c0 4.5 6 10 6 10s6-5.5 6-10a6 6 0 00-6-6zm0 8a2 2 0 110-4 2 2 0 010 4z" \
2
create_domain "SHIP" \
"M3 15l1.5-6h11L17 15M5 15l-2 3h14l-2-3M7 9V5a1 1 0 011-1h4a1 1 0 011 1v4" \
3
create_domain "PORT" \
"M3 17h14M5 17V7l5-4 5 4v10M8 17v-3h4v3M8 10h.01M12 10h.01" \
4
create_domain "TRADE" \
"M4 6h12M4 6v10a1 1 0 001 1h10a1 1 0 001-1V6M4 6l1-3h10l1 3M8 10h4M8 13h4" \
5
create_domain "WEATHER" \
"M3 13.5c0-1.38 1.12-2.5 2.5-2.5.39 0 .76.09 1.09.25A4.002 4.002 0 0110.5 8c1.82 0 3.36 1.22 3.84 2.88A2.5 2.5 0 0117 13.5 2.5 2.5 0 0114.5 16h-9A2.5 2.5 0 013 13.5z" \
6
create_domain "COMPLIANCE" \
"M9 12l2 2 4-4m-3-5.96A8 8 0 1017.96 14H10V6.04z" \
7
create_domain "MONITORING" \
"M3 13h2l2-4 3 8 2-6 2 2h3M3 17h14" \
8
echo ""
echo "=== 완료 ==="
echo "기존 API의 api_domain 값과 도메인명을 일치시켜야 사이드바에 아이콘이 표시됩니다."
echo "Admin > Domains 에서 추가/수정/삭제할 수 있습니다."

파일 보기

@ -6,6 +6,8 @@ import java.util.List;
public record DomainGroup( public record DomainGroup(
String domain, String domain,
String iconPath,
int sortOrder,
List<ServiceApiResponse> apis List<ServiceApiResponse> apis
) { ) {
} }

파일 보기

@ -1,6 +1,7 @@
package com.gcsc.connection.apihub.dto; package com.gcsc.connection.apihub.dto;
import com.gcsc.connection.service.dto.ServiceApiResponse; import com.gcsc.connection.service.dto.ServiceApiResponse;
import com.gcsc.connection.service.entity.SnpApiDomain;
import com.gcsc.connection.service.entity.SnpService; import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi; import com.gcsc.connection.service.entity.SnpServiceApi;
@ -12,13 +13,27 @@ public record ServiceCatalogResponse(
Long serviceId, Long serviceId,
String serviceCode, String serviceCode,
String serviceName, String serviceName,
String serviceUrl,
String description, String description,
String healthStatus, String healthStatus,
int apiCount, int apiCount,
List<DomainGroup> domains List<DomainGroup> domains
) { ) {
/**
* 도메인 메타 정보(아이콘, 정렬) 없이 도메인명 기준 알파벳 정렬로 카탈로그 생성
*/
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis) { public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis) {
return from(service, apis, Map.of());
}
/**
* 도메인 메타 정보(아이콘, 정렬) 포함하여 카탈로그 생성
*
* @param domainMap domainName SnpApiDomain 매핑
*/
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis,
Map<String, SnpApiDomain> domainMap) {
Map<String, List<ServiceApiResponse>> byDomain = apis.stream() Map<String, List<ServiceApiResponse>> byDomain = apis.stream()
.collect(Collectors.groupingBy( .collect(Collectors.groupingBy(
api -> api.getApiDomain() != null ? api.getApiDomain() : "", api -> api.getApiDomain() != null ? api.getApiDomain() : "",
@ -26,14 +41,29 @@ public record ServiceCatalogResponse(
)); ));
List<DomainGroup> domainGroups = byDomain.entrySet().stream() List<DomainGroup> domainGroups = byDomain.entrySet().stream()
.sorted(Map.Entry.comparingByKey()) .sorted((a, b) -> {
.map(entry -> new DomainGroup(entry.getKey(), entry.getValue())) SnpApiDomain da = domainMap.get(a.getKey());
SnpApiDomain db = domainMap.get(b.getKey());
int orderA = da != null ? da.getSortOrder() : Integer.MAX_VALUE;
int orderB = db != null ? db.getSortOrder() : Integer.MAX_VALUE;
if (orderA != orderB) {
return Integer.compare(orderA, orderB);
}
return a.getKey().compareTo(b.getKey());
})
.map(entry -> {
SnpApiDomain domainMeta = domainMap.get(entry.getKey());
String iconPath = domainMeta != null ? domainMeta.getIconPath() : null;
int sortOrder = domainMeta != null ? domainMeta.getSortOrder() : Integer.MAX_VALUE;
return new DomainGroup(entry.getKey(), iconPath, sortOrder, entry.getValue());
})
.toList(); .toList();
return new ServiceCatalogResponse( return new ServiceCatalogResponse(
service.getServiceId(), service.getServiceId(),
service.getServiceCode(), service.getServiceCode(),
service.getServiceName(), service.getServiceName(),
service.getServiceUrl(),
service.getDescription(), service.getDescription(),
service.getHealthStatus().name(), service.getHealthStatus().name(),
apis.size(), apis.size(),

파일 보기

@ -2,8 +2,12 @@ package com.gcsc.connection.apihub.service;
import com.gcsc.connection.apihub.dto.RecentApiResponse; import com.gcsc.connection.apihub.dto.RecentApiResponse;
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse; import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.service.entity.SnpApiDomain;
import com.gcsc.connection.service.entity.SnpService; import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi; import com.gcsc.connection.service.entity.SnpServiceApi;
import com.gcsc.connection.service.repository.SnpApiDomainRepository;
import com.gcsc.connection.service.repository.SnpServiceApiRepository; import com.gcsc.connection.service.repository.SnpServiceApiRepository;
import com.gcsc.connection.service.repository.SnpServiceRepository; import com.gcsc.connection.service.repository.SnpServiceRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -12,6 +16,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@ -20,23 +27,41 @@ public class ApiHubService {
private final SnpServiceRepository snpServiceRepository; private final SnpServiceRepository snpServiceRepository;
private final SnpServiceApiRepository snpServiceApiRepository; private final SnpServiceApiRepository snpServiceApiRepository;
private final SnpApiDomainRepository snpApiDomainRepository;
/** /**
* 활성 서비스와 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환 * 활성 서비스와 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ServiceCatalogResponse> getCatalog() { public List<ServiceCatalogResponse> getCatalog() {
Map<String, SnpApiDomain> domainMap = buildDomainMap();
List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue(); List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue();
return activeServices.stream() return activeServices.stream()
.map(service -> { .map(service -> {
List<SnpServiceApi> activeApis = snpServiceApiRepository List<SnpServiceApi> activeApis = snpServiceApiRepository
.findByServiceServiceIdAndIsActiveTrue(service.getServiceId()); .findByServiceServiceIdAndIsActiveTrue(service.getServiceId());
return ServiceCatalogResponse.from(service, activeApis); return ServiceCatalogResponse.from(service, activeApis, domainMap);
}) })
.toList(); .toList();
} }
/**
* 서비스 단건 카탈로그 조회
*/
@Transactional(readOnly = true)
public ServiceCatalogResponse getServiceCatalog(Long serviceId) {
SnpService service = snpServiceRepository.findById(serviceId)
.filter(SnpService::getIsActive)
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
List<SnpServiceApi> activeApis = snpServiceApiRepository
.findByServiceServiceIdAndIsActiveTrue(serviceId);
Map<String, SnpApiDomain> domainMap = buildDomainMap();
return ServiceCatalogResponse.from(service, activeApis, domainMap);
}
/** /**
* 최근 등록된 활성 API 상위 10건 반환 * 최근 등록된 활성 API 상위 10건 반환
*/ */
@ -46,4 +71,9 @@ public class ApiHubService {
.map(RecentApiResponse::from) .map(RecentApiResponse::from)
.toList(); .toList();
} }
private Map<String, SnpApiDomain> buildDomainMap() {
return snpApiDomainRepository.findAllByOrderBySortOrderAscDomainNameAsc().stream()
.collect(Collectors.toMap(SnpApiDomain::getDomainName, Function.identity()));
}
} }

파일 보기

@ -0,0 +1,80 @@
package com.gcsc.connection.service.controller;
import com.gcsc.connection.common.dto.ApiResponse;
import com.gcsc.connection.service.dto.ApiDomainResponse;
import com.gcsc.connection.service.dto.SaveApiDomainRequest;
import com.gcsc.connection.service.entity.SnpApiDomain;
import com.gcsc.connection.service.repository.SnpApiDomainRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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 도메인 관리 API
*/
@RestController
@RequestMapping("/api/domains")
@RequiredArgsConstructor
public class ApiDomainController {
private final SnpApiDomainRepository snpApiDomainRepository;
/**
* 전체 도메인 목록 조회 (sortOrder 오름차순)
*/
@GetMapping
public ResponseEntity<ApiResponse<List<ApiDomainResponse>>> getDomains() {
List<ApiDomainResponse> domains = snpApiDomainRepository.findAllByOrderBySortOrderAscDomainNameAsc()
.stream()
.map(ApiDomainResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.ok(domains));
}
/**
* 도메인 생성
*/
@PostMapping
public ResponseEntity<ApiResponse<ApiDomainResponse>> createDomain(
@RequestBody SaveApiDomainRequest request) {
SnpApiDomain domain = SnpApiDomain.builder()
.domainName(request.domainName())
.iconPath(request.iconPath())
.sortOrder(request.sortOrder())
.build();
SnpApiDomain saved = snpApiDomainRepository.save(domain);
return ResponseEntity.ok(ApiResponse.ok(ApiDomainResponse.from(saved)));
}
/**
* 도메인 수정
*/
@PutMapping("/{domainId}")
public ResponseEntity<ApiResponse<ApiDomainResponse>> updateDomain(
@PathVariable Long domainId,
@RequestBody SaveApiDomainRequest request) {
SnpApiDomain domain = snpApiDomainRepository.findById(domainId)
.orElseThrow(() -> new IllegalArgumentException("도메인을 찾을 수 없습니다: " + domainId));
domain.update(request.domainName(), request.iconPath(), request.sortOrder());
SnpApiDomain saved = snpApiDomainRepository.save(domain);
return ResponseEntity.ok(ApiResponse.ok(ApiDomainResponse.from(saved)));
}
/**
* 도메인 삭제
*/
@DeleteMapping("/{domainId}")
public ResponseEntity<ApiResponse<Void>> deleteDomain(@PathVariable Long domainId) {
snpApiDomainRepository.deleteById(domainId);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

파일 보기

@ -0,0 +1,26 @@
package com.gcsc.connection.service.dto;
import com.gcsc.connection.service.entity.SnpApiDomain;
import java.time.LocalDateTime;
public record ApiDomainResponse(
Long domainId,
String domainName,
String iconPath,
Integer sortOrder,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static ApiDomainResponse from(SnpApiDomain domain) {
return new ApiDomainResponse(
domain.getDomainId(),
domain.getDomainName(),
domain.getIconPath(),
domain.getSortOrder(),
domain.getCreatedAt(),
domain.getUpdatedAt()
);
}
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.connection.service.dto;
public record SaveApiDomainRequest(
String domainName,
String iconPath,
Integer sortOrder
) {
}

파일 보기

@ -0,0 +1,46 @@
package com.gcsc.connection.service.entity;
import com.gcsc.connection.common.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
@Table(name = "snp_api_domain", schema = "common")
public class SnpApiDomain extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "domain_id")
private Long domainId;
@Column(name = "domain_name", length = 100, unique = true, nullable = false)
private String domainName;
@Column(name = "icon_path", columnDefinition = "TEXT")
private String iconPath;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
@Builder
public SnpApiDomain(String domainName, String iconPath, Integer sortOrder) {
this.domainName = domainName;
this.iconPath = iconPath;
this.sortOrder = sortOrder != null ? sortOrder : 0;
}
public void update(String domainName, String iconPath, Integer sortOrder) {
if (domainName != null) this.domainName = domainName;
if (iconPath != null) this.iconPath = iconPath;
if (sortOrder != null) this.sortOrder = sortOrder;
}
}

파일 보기

@ -0,0 +1,14 @@
package com.gcsc.connection.service.repository;
import com.gcsc.connection.service.entity.SnpApiDomain;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface SnpApiDomainRepository extends JpaRepository<SnpApiDomain, Long> {
Optional<SnpApiDomain> findByDomainName(String domainName);
List<SnpApiDomain> findAllByOrderBySortOrderAscDomainNameAsc();
}