generated from gc/template-java-maven
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:
부모
dfee04f703
커밋
17d870c06a
@ -11,6 +11,10 @@ import MyKeysPage from './pages/apikeys/MyKeysPage';
|
||||
import KeyRequestPage from './pages/apikeys/KeyRequestPage';
|
||||
import KeyAdminPage from './pages/apikeys/KeyAdminPage';
|
||||
import ServicesPage from './pages/admin/ServicesPage';
|
||||
import 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 TenantsPage from './pages/admin/TenantsPage';
|
||||
import ServiceStatsPage from './pages/statistics/ServiceStatsPage';
|
||||
@ -49,6 +53,10 @@ const App = () => {
|
||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||
<Route path="/apikeys/admin" element={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></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/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} />
|
||||
<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 { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
@ -7,22 +7,28 @@ import type { ServiceCatalog } from '../types/apihub';
|
||||
|
||||
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
|
||||
|
||||
const METHOD_BADGE_CLASS: Record<string, string> = {
|
||||
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',
|
||||
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
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'];
|
||||
|
||||
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
|
||||
const parseIconPaths = (iconPath: string | null): string[] => {
|
||||
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 =>
|
||||
METHOD_BADGE_CLASS[method.toUpperCase()] ??
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
|
||||
const HEALTH_DOT_CLASS: Record<ServiceCatalog['healthStatus'], string> = {
|
||||
UP: 'bg-green-500',
|
||||
DOWN: 'bg-red-500',
|
||||
UNKNOWN: 'bg-gray-400',
|
||||
};
|
||||
interface FlatDomainGroup {
|
||||
domain: string;
|
||||
iconPath: string | null;
|
||||
sortOrder: number;
|
||||
apis: { serviceId: number; apiId: number; apiName: string; apiPath: string; apiMethod: string }[];
|
||||
}
|
||||
|
||||
const ApiHubLayout = () => {
|
||||
const { user, setRole } = useAuth();
|
||||
@ -32,37 +38,68 @@ const ApiHubLayout = () => {
|
||||
|
||||
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openServices, setOpenServices] = useState<Record<number, boolean>>({});
|
||||
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getCatalog()
|
||||
.then((res) => {
|
||||
const items = res.data ?? [];
|
||||
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));
|
||||
}, []);
|
||||
|
||||
const toggleService = (serviceId: number) => {
|
||||
setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] }));
|
||||
};
|
||||
|
||||
const toggleDomain = (key: string) => {
|
||||
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 (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
@ -98,8 +135,39 @@ const ApiHubLayout = () => {
|
||||
</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 */}
|
||||
<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 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<svg
|
||||
@ -122,95 +190,64 @@ const ApiHubLayout = () => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : filteredDomainGroups.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 text-center py-6">검색 결과가 없습니다</p>
|
||||
) : (
|
||||
catalog.map((service) => {
|
||||
const serviceOpen = openServices[service.serviceId] ?? false;
|
||||
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;
|
||||
filteredDomainGroups.map((dg) => {
|
||||
const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
|
||||
|
||||
return (
|
||||
<div key={dg.domain}>
|
||||
{/* Domain header */}
|
||||
<button
|
||||
onClick={() => toggleDomain(domainKey)}
|
||||
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"
|
||||
onClick={() => toggleDomain(dg.domain)}
|
||||
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
|
||||
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"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</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>
|
||||
|
||||
{/* API items */}
|
||||
{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) => {
|
||||
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;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={api.apiId}
|
||||
key={`${api.serviceId}-${api.apiId}`}
|
||||
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
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
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>
|
||||
{api.apiName}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@ -218,11 +255,6 @@ const ApiHubLayout = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@ -40,6 +40,9 @@ const navGroups: NavGroup[] = [
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ 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: 'Tenants', path: '/admin/tenants' },
|
||||
],
|
||||
|
||||
302
frontend/src/pages/admin/DomainsPage.tsx
Normal file
302
frontend/src/pages/admin/DomainsPage.tsx
Normal file
@ -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 {
|
||||
domain: string;
|
||||
iconPath: string | null;
|
||||
sortOrder: number;
|
||||
apis: ServiceApiItem[];
|
||||
}
|
||||
|
||||
export interface ApiDomainInfo {
|
||||
domainId: number;
|
||||
domainName: string;
|
||||
iconPath: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceApiItem {
|
||||
apiId: number;
|
||||
serviceId: number;
|
||||
@ -22,6 +33,7 @@ export interface ServiceCatalog {
|
||||
serviceCode: string;
|
||||
serviceName: string;
|
||||
description: string | null;
|
||||
serviceUrl: string | null;
|
||||
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
|
||||
apiCount: number;
|
||||
domains: DomainGroup[];
|
||||
|
||||
55
scripts/init-domains.sh
Normal file
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(
|
||||
String domain,
|
||||
String iconPath,
|
||||
int sortOrder,
|
||||
List<ServiceApiResponse> apis
|
||||
) {
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.gcsc.connection.apihub.dto;
|
||||
|
||||
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.SnpServiceApi;
|
||||
|
||||
@ -12,13 +13,27 @@ public record ServiceCatalogResponse(
|
||||
Long serviceId,
|
||||
String serviceCode,
|
||||
String serviceName,
|
||||
String serviceUrl,
|
||||
String description,
|
||||
String healthStatus,
|
||||
int apiCount,
|
||||
List<DomainGroup> domains
|
||||
) {
|
||||
|
||||
/**
|
||||
* 도메인 메타 정보(아이콘, 정렬) 없이 도메인명 기준 알파벳 정렬로 카탈로그 생성
|
||||
*/
|
||||
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()
|
||||
.collect(Collectors.groupingBy(
|
||||
api -> api.getApiDomain() != null ? api.getApiDomain() : "",
|
||||
@ -26,14 +41,29 @@ public record ServiceCatalogResponse(
|
||||
));
|
||||
|
||||
List<DomainGroup> domainGroups = byDomain.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.map(entry -> new DomainGroup(entry.getKey(), entry.getValue()))
|
||||
.sorted((a, b) -> {
|
||||
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();
|
||||
|
||||
return new ServiceCatalogResponse(
|
||||
service.getServiceId(),
|
||||
service.getServiceCode(),
|
||||
service.getServiceName(),
|
||||
service.getServiceUrl(),
|
||||
service.getDescription(),
|
||||
service.getHealthStatus().name(),
|
||||
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.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.SnpServiceApi;
|
||||
import com.gcsc.connection.service.repository.SnpApiDomainRepository;
|
||||
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
||||
import com.gcsc.connection.service.repository.SnpServiceRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -12,6 +16,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@ -20,23 +27,41 @@ public class ApiHubService {
|
||||
|
||||
private final SnpServiceRepository snpServiceRepository;
|
||||
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||
private final SnpApiDomainRepository snpApiDomainRepository;
|
||||
|
||||
/**
|
||||
* 활성 서비스와 각 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ServiceCatalogResponse> getCatalog() {
|
||||
Map<String, SnpApiDomain> domainMap = buildDomainMap();
|
||||
List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue();
|
||||
|
||||
return activeServices.stream()
|
||||
.map(service -> {
|
||||
List<SnpServiceApi> activeApis = snpServiceApiRepository
|
||||
.findByServiceServiceIdAndIsActiveTrue(service.getServiceId());
|
||||
return ServiceCatalogResponse.from(service, activeApis);
|
||||
return ServiceCatalogResponse.from(service, activeApis, domainMap);
|
||||
})
|
||||
.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건 반환
|
||||
*/
|
||||
@ -46,4 +71,9 @@ public class ApiHubService {
|
||||
.map(RecentApiResponse::from)
|
||||
.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();
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user