From 17d870c06a2a3690698f61fd2c080c25be094e5a Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Tue, 14 Apr 2026 13:59:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(domain):=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20API=20HUB?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/src/App.tsx | 8 + frontend/src/layouts/ApiHubLayout.tsx | 270 +++++++++------- frontend/src/layouts/MainLayout.tsx | 3 + frontend/src/pages/admin/DomainsPage.tsx | 302 ++++++++++++++++++ frontend/src/types/apihub.ts | 12 + scripts/init-domains.sh | 55 ++++ .../connection/apihub/dto/DomainGroup.java | 2 + .../apihub/dto/ServiceCatalogResponse.java | 34 +- .../apihub/service/ApiHubService.java | 32 +- .../controller/ApiDomainController.java | 80 +++++ .../service/dto/ApiDomainResponse.java | 26 ++ .../service/dto/SaveApiDomainRequest.java | 8 + .../service/entity/SnpApiDomain.java | 46 +++ .../repository/SnpApiDomainRepository.java | 14 + 14 files changed, 770 insertions(+), 122 deletions(-) create mode 100644 frontend/src/pages/admin/DomainsPage.tsx create mode 100644 scripts/init-domains.sh create mode 100644 src/main/java/com/gcsc/connection/service/controller/ApiDomainController.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/ApiDomainResponse.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/SaveApiDomainRequest.java create mode 100644 src/main/java/com/gcsc/connection/service/entity/SnpApiDomain.java create mode 100644 src/main/java/com/gcsc/connection/service/repository/SnpApiDomainRepository.java diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3fc7cd..c9ebd69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/layouts/ApiHubLayout.tsx b/frontend/src/layouts/ApiHubLayout.tsx index b47cd56..07f6bc8 100644 --- a/frontend/src/layouts/ApiHubLayout.tsx +++ b/frontend/src/layouts/ApiHubLayout.tsx @@ -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 = { - 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; + // 형태에서 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 = { - 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([]); const [loading, setLoading] = useState(true); - const [openServices, setOpenServices] = useState>({}); const [openDomains, setOpenDomains] = useState>({}); + 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 = {}; - const domainState: Record = {}; - 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(() => { + const map = new Map(); + 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 => dg !== null); + }, [domainGroups, searchQuery]); + + const isSearching = searchQuery.trim().length > 0; + return (
{/* Sidebar */} @@ -98,8 +135,39 @@ const ApiHubLayout = () => {
+ {/* Search */} +
+
+ + + + 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 && ( + + )} +
+
+ {/* Navigation tree */} -