Merge pull request 'release: 2026-04-10 (5건 커밋)' (#34) from develop into main
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s

This commit is contained in:
HYOJIN 2026-04-10 11:07:58 +09:00
커밋 2468f9baca
13개의 변경된 파일1687개의 추가작업 그리고 434개의 파일을 삭제

파일 보기

@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased] ## [Unreleased]
## [2026-04-10]
### 추가
- 서비스 API 도메인 분류 + 계층형 API 선택 UI (#31)
- API Key 검토: API 권한 편집 + API 추가 모달 (#31)
- API Key 관리: KPI 카드, 필터 칩, 검색, 페이징 (#31)
- 키 상세: 상태별 색상, 보기/숨기기+복사 (#31)
### 변경
- API Key 관리 UI 전면 개선 (레퍼런스 디자인 적용) (#31)
- 검토 모달: 탭 분리, 변경테이블, readOnly 상세 (#31)
- 테이블 필드 한글화/순서 변경/소유자 표시 (#31)
## [2026-04-09] ## [2026-04-09]
### 추가 ### 추가

파일 보기

@ -91,6 +91,8 @@ CREATE TABLE IF NOT EXISTS snp_service_api (
api_path VARCHAR(500) NOT NULL, api_path VARCHAR(500) NOT NULL,
api_method VARCHAR(10) NOT NULL, api_method VARCHAR(10) NOT NULL,
api_name VARCHAR(200) NOT NULL, api_name VARCHAR(200) NOT NULL,
api_domain VARCHAR(100),
api_section VARCHAR(100),
description TEXT, description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),

파일 보기

@ -61,6 +61,8 @@ const ServicesPage = () => {
const [apiMethod, setApiMethod] = useState('GET'); const [apiMethod, setApiMethod] = useState('GET');
const [apiPath, setApiPath] = useState(''); const [apiPath, setApiPath] = useState('');
const [apiName, setApiName] = useState(''); const [apiName, setApiName] = useState('');
const [apiDomain, setApiDomain] = useState('');
const [apiSection, setApiSection] = useState('');
const [apiDescription, setApiDescription] = useState(''); const [apiDescription, setApiDescription] = useState('');
const fetchServices = async () => { const fetchServices = async () => {
@ -174,6 +176,8 @@ const ServicesPage = () => {
setApiMethod('GET'); setApiMethod('GET');
setApiPath(''); setApiPath('');
setApiName(''); setApiName('');
setApiDomain('');
setApiSection('');
setApiDescription(''); setApiDescription('');
setIsApiModalOpen(true); setIsApiModalOpen(true);
}; };
@ -193,6 +197,8 @@ const ServicesPage = () => {
apiMethod, apiMethod,
apiPath, apiPath,
apiName, apiName,
apiDomain: apiDomain || undefined,
apiSection: apiSection || undefined,
description: apiDescription || undefined, description: apiDescription || undefined,
}; };
const res = await createServiceApi(selectedService.serviceId, req); const res = await createServiceApi(selectedService.serviceId, req);
@ -537,6 +543,28 @@ const ServicesPage = () => {
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" 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="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
<input
type="text"
value={apiDomain}
onChange={(e) => setApiDomain(e.target.value)}
placeholder="예: Compliance"
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="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
<input
type="text"
value={apiSection}
onChange={(e) => setApiSection(e.target.value)}
placeholder="예: 선박 규정"
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> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description Description

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,14 +1,47 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { ServiceInfo, ServiceApi } from '../../types/service'; import type { ServiceInfo, ServiceApi } from '../../types/service';
import { getServices, getServiceApis } from '../../services/serviceService'; import { getServices, getServiceApis } from '../../services/serviceService';
import { createKeyRequest } from '../../services/apiKeyService'; import { createKeyRequest } from '../../services/apiKeyService';
const METHOD_COLOR: Record<string, string> = { const METHOD_BADGE_STYLE: Record<string, string> = {
GET: 'bg-green-100 text-green-800', GET: 'bg-emerald-900/80 text-emerald-300 border border-emerald-700/50',
POST: 'bg-blue-100 text-blue-800', POST: 'bg-amber-900/80 text-amber-300 border border-amber-700/50',
PUT: 'bg-orange-100 text-orange-800', PUT: 'bg-blue-900/80 text-blue-300 border border-blue-700/50',
DELETE: 'bg-red-100 text-red-800', DELETE: 'bg-red-900/80 text-red-300 border border-red-700/50',
PATCH: 'bg-purple-900/80 text-purple-300 border border-purple-700/50',
};
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} className={className || 'rounded'} />;
};
interface DomainGroup {
domain: string;
apis: ServiceApi[];
}
const groupApisByDomain = (apis: ServiceApi[]): DomainGroup[] => {
const domainMap = new Map<string, ServiceApi[]>();
apis.forEach((api) => {
const domain = api.apiDomain || '미분류';
if (!domainMap.has(domain)) {
domainMap.set(domain, []);
}
domainMap.get(domain)!.push(api);
});
const result: DomainGroup[] = [];
domainMap.forEach((domainApis, domain) => {
result.push({ domain, apis: domainApis });
});
return result;
}; };
const KeyRequestPage = () => { const KeyRequestPage = () => {
@ -17,6 +50,7 @@ const KeyRequestPage = () => {
const [services, setServices] = useState<ServiceInfo[]>([]); const [services, setServices] = useState<ServiceInfo[]>([]);
const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({}); const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({});
const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set()); const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set());
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set()); const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
const [keyName, setKeyName] = useState(''); const [keyName, setKeyName] = useState('');
const [purpose, setPurpose] = useState(''); const [purpose, setPurpose] = useState('');
@ -27,6 +61,7 @@ const KeyRequestPage = () => {
const [isPermanent, setIsPermanent] = useState(false); const [isPermanent, setIsPermanent] = useState(false);
const [usageFromDate, setUsageFromDate] = useState(''); const [usageFromDate, setUsageFromDate] = useState('');
const [usageToDate, setUsageToDate] = useState(''); const [usageToDate, setUsageToDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -64,6 +99,14 @@ const KeyRequestPage = () => {
fetchData(); fetchData();
}, []); }, []);
const groupedApisMap = useMemo(() => {
const result: Record<number, DomainGroup[]> = {};
Object.entries(serviceApisMap).forEach(([serviceId, apis]) => {
result[Number(serviceId)] = groupApisByDomain(apis);
});
return result;
}, [serviceApisMap]);
const handleToggleService = (serviceId: number) => { const handleToggleService = (serviceId: number) => {
setExpandedServices((prev) => { setExpandedServices((prev) => {
const next = new Set(prev); const next = new Set(prev);
@ -76,6 +119,18 @@ const KeyRequestPage = () => {
}); });
}; };
const handleToggleDomain = (key: string) => {
setExpandedDomains((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const handleToggleApi = (apiId: number) => { const handleToggleApi = (apiId: number) => {
setSelectedApiIds((prev) => { setSelectedApiIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@ -105,6 +160,70 @@ const KeyRequestPage = () => {
}); });
}; };
const handleToggleAllDomainApis = (serviceId: number, domain: string) => {
const domainGroups = groupedApisMap[serviceId] || [];
const domainGroup = domainGroups.find((d) => d.domain === domain);
if (!domainGroup) return;
const domainApis = domainGroup.apis;
const allSelected = domainApis.every((a) => selectedApiIds.has(a.apiId));
setSelectedApiIds((prev) => {
const next = new Set(prev);
domainApis.forEach((a) => {
if (allSelected) {
next.delete(a.apiId);
} else {
next.add(a.apiId);
}
});
return next;
});
};
const allApis = useMemo(() => {
return Object.values(serviceApisMap).flat();
}, [serviceApisMap]);
const allApisSelected = allApis.length > 0 && allApis.every((a) => selectedApiIds.has(a.apiId));
const someApisSelected = allApis.some((a) => selectedApiIds.has(a.apiId));
const handleToggleAll = () => {
if (allApisSelected) {
setSelectedApiIds(new Set());
} else {
setSelectedApiIds(new Set(allApis.map((a) => a.apiId)));
}
};
const handleClearSelection = () => {
setSelectedApiIds(new Set());
};
const filteredGroupedApisMap = useMemo(() => {
if (!searchQuery.trim()) return groupedApisMap;
const query = searchQuery.toLowerCase();
const result: Record<number, DomainGroup[]> = {};
Object.entries(groupedApisMap).forEach(([serviceId, domainGroups]) => {
const filtered = domainGroups
.map((dg) => ({
domain: dg.domain,
apis: dg.apis.filter(
(a) =>
a.apiName.toLowerCase().includes(query) ||
a.apiPath.toLowerCase().includes(query),
),
}))
.filter((dg) => dg.apis.length > 0);
if (filtered.length > 0) {
result[Number(serviceId)] = filtered;
}
});
return result;
}, [groupedApisMap, searchQuery]);
const handlePresetPeriod = (months: number) => { const handlePresetPeriod = (months: number) => {
const from = new Date(); const from = new Date();
const to = new Date(); const to = new Date();
@ -321,78 +440,173 @@ const KeyRequestPage = () => {
</div> </div>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6"> {/* API Selection Section */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow mb-6 overflow-hidden">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> {/* Header bar */}
API <span className="text-sm font-normal text-gray-500 dark:text-gray-400 dark:text-gray-400">({selectedApiIds.size} )</span> <div className="flex items-center justify-between gap-4 px-5 py-3.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
</h2> <div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer" onClick={(e) => e.stopPropagation()}>
<IndeterminateCheckbox
checked={allApisSelected}
indeterminate={!allApisSelected && someApisSelected}
onChange={handleToggleAll}
className="rounded"
/>
</label>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API </h2>
{selectedApiIds.size > 0 && (
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full">
{selectedApiIds.size}
</span>
)}
</div> </div>
<div className="divide-y divide-gray-200 dark:divide-gray-700"> <div className="flex items-center gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="API 검색..."
className="bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none w-56"
/>
{selectedApiIds.size > 0 && (
<button
type="button"
onClick={handleClearSelection}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 px-2 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
</button>
)}
</div>
</div>
{/* Service cards */}
<div className="p-4 space-y-3">
{services.map((service) => { {services.map((service) => {
const apis = serviceApisMap[service.serviceId] || []; const apis = serviceApisMap[service.serviceId] || [];
const isExpanded = expandedServices.has(service.serviceId); const domainGroups = filteredGroupedApisMap[service.serviceId] || [];
const isServiceExpanded = expandedServices.has(service.serviceId);
const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length; const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length;
const allSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId)); const allServiceSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId));
const someServiceSelected = !allServiceSelected && apis.some((a) => selectedApiIds.has(a.apiId));
const hasSelections = selectedCount > 0;
if (searchQuery.trim() && domainGroups.length === 0) return null;
return ( return (
<div key={service.serviceId}>
<div <div
className="px-6 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700" key={service.serviceId}
className={`rounded-xl border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300 dark:border-blue-700' : 'border-gray-200 dark:border-gray-700'}`}
>
{/* Service header */}
<div
className={`flex items-center justify-between px-5 py-3.5 cursor-pointer ${hasSelections ? 'bg-blue-50/50 dark:bg-blue-900/20' : 'bg-gray-50 dark:bg-gray-800/80'}`}
onClick={() => handleToggleService(service.serviceId)} onClick={() => handleToggleService(service.serviceId)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-gray-400 dark:text-gray-500 text-sm">{isExpanded ? '\u25BC' : '\u25B6'}</span> <svg className={`h-4 w-4 text-gray-400 transition-transform ${isServiceExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<span className="font-medium text-gray-900 dark:text-gray-100">{service.serviceName}</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
{selectedCount > 0 && ( </svg>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full"> <label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
{selectedCount}/{apis.length} <IndeterminateCheckbox
</span> checked={allServiceSelected}
)} indeterminate={someServiceSelected}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">{apis.length} API</span>
</div>
{isExpanded && (
<div className="px-6 pb-3">
{apis.length > 0 && (
<div className="mb-2 pl-6">
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={allSelected}
onChange={() => handleToggleAllServiceApis(service.serviceId)} onChange={() => handleToggleAllServiceApis(service.serviceId)}
className="rounded" className="rounded"
/> />
</label> </label>
</div> <span className="font-semibold text-gray-900 dark:text-gray-100">{service.serviceName}</span>
{selectedCount > 0 && (
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
{selectedCount} selected
</span>
)} )}
<div className="space-y-1 pl-6"> </div>
{apis.map((api) => ( <span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
<label {apis.length} API
key={api.apiId} </span>
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2" </div>
{/* Service body */}
{isServiceExpanded && (
<div className="px-4 py-3 space-y-2 bg-white dark:bg-gray-900">
{domainGroups.map((domainGroup) => {
const domainKey = `${service.serviceId}-${domainGroup.domain}`;
const isDomainExpanded = expandedDomains.has(domainKey);
const domainApis = domainGroup.apis;
const allDomainSelected = domainApis.length > 0 && domainApis.every((a) => selectedApiIds.has(a.apiId));
const someDomainSelected = !allDomainSelected && domainApis.some((a) => selectedApiIds.has(a.apiId));
return (
<div key={domainKey}>
{/* Domain row */}
<div
className="ml-5 flex items-center gap-2.5 px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => handleToggleDomain(domainKey)}
> >
<input <svg className={`h-3.5 w-3.5 text-gray-400 transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
type="checkbox" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
checked={selectedApiIds.has(api.apiId)} </svg>
onChange={() => handleToggleApi(api.apiId)} <label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
<IndeterminateCheckbox
checked={allDomainSelected}
indeterminate={someDomainSelected}
onChange={() => handleToggleAllDomainApis(service.serviceId, domainGroup.domain)}
className="rounded" className="rounded"
/> />
<span </label>
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${ <span className="text-sm font-semibold text-gray-800 dark:text-gray-200">{domainGroup.domain}</span>
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' <span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
}`} {domainApis.length}
</span>
</div>
{/* API table */}
{isDomainExpanded && (
<div className="ml-3 mr-3 mb-3 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Table header */}
<div className="grid grid-cols-[24px_56px_1fr_1fr] gap-3 items-center bg-gray-50 dark:bg-gray-800 px-4 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-500 uppercase tracking-wider">
<div></div>
<div>Method</div>
<div>Path</div>
<div>Name</div>
</div>
{/* Table rows */}
{domainApis.map((api) => {
const isSelected = selectedApiIds.has(api.apiId);
return (
<div
key={api.apiId}
className={`grid grid-cols-[24px_56px_1fr_1fr] gap-3 items-center px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 cursor-pointer ${isSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
onClick={() => handleToggleApi(api.apiId)}
> >
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleApi(api.apiId)}
onClick={(e) => e.stopPropagation()}
className="rounded"
/>
</div>
<div>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${METHOD_BADGE_STYLE[api.apiMethod] || 'bg-gray-700 text-gray-300 border border-gray-600'}`}>
{api.apiMethod} {api.apiMethod}
</span> </span>
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{api.apiPath}</span> </div>
<span className="text-sm text-gray-500 dark:text-gray-400">- {api.apiName}</span> <div className="font-mono text-xs text-gray-700 dark:text-gray-300 truncate">{api.apiPath}</div>
</label> <div className="text-sm text-gray-600 dark:text-gray-400 truncate">{api.apiName}</div>
))} </div>
{apis.length === 0 && ( );
<p className="text-sm text-gray-400 dark:text-gray-500 py-1"> API가 .</p> })}
</div>
)} )}
</div> </div>
);
})}
{apis.length === 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500 py-3 ml-5"> API가 .</p>
)}
</div> </div>
)} )}
</div> </div>
@ -406,6 +620,22 @@ const KeyRequestPage = () => {
</div> </div>
</div> </div>
{/* Bottom sticky summary bar */}
{selectedApiIds.size > 0 && (
<div className="sticky bottom-4 z-10 mx-auto mb-4">
<div className="bg-blue-600 dark:bg-blue-700 text-white rounded-xl px-5 py-3 shadow-lg flex items-center justify-between">
<span className="text-sm font-medium">{selectedApiIds.size} API가 </span>
<button
type="button"
onClick={handleClearSelection}
className="text-sm text-blue-200 hover:text-white transition-colors"
>
</button>
</div>
</div>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="submit" type="submit"

파일 보기

@ -4,6 +4,7 @@ export interface ApiKey {
apiKeyPrefix: string; apiKeyPrefix: string;
maskedKey: string; maskedKey: string;
status: 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'REVOKED'; status: 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'REVOKED';
userName: string | null;
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
createdAt: string; createdAt: string;

파일 보기

@ -19,6 +19,8 @@ export interface ServiceApi {
apiPath: string; apiPath: string;
apiMethod: string; apiMethod: string;
apiName: string; apiName: string;
apiDomain: string | null;
apiSection: string | null;
description: string | null; description: string | null;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
@ -46,6 +48,8 @@ export interface CreateServiceApiRequest {
apiPath: string; apiPath: string;
apiMethod: string; apiMethod: string;
apiName: string; apiName: string;
apiDomain?: string;
apiSection?: string;
description?: string; description?: string;
} }

파일 보기

@ -10,6 +10,7 @@ public record ApiKeyResponse(
String apiKeyPrefix, String apiKeyPrefix,
String maskedKey, String maskedKey,
String status, String status,
String userName,
LocalDateTime expiresAt, LocalDateTime expiresAt,
LocalDateTime lastUsedAt, LocalDateTime lastUsedAt,
LocalDateTime createdAt LocalDateTime createdAt
@ -17,12 +18,14 @@ public record ApiKeyResponse(
public static ApiKeyResponse from(SnpApiKey key) { public static ApiKeyResponse from(SnpApiKey key) {
String masked = key.getApiKeyPrefix() + "****...****"; String masked = key.getApiKeyPrefix() + "****...****";
String ownerName = key.getUser() != null ? key.getUser().getUserName() : null;
return new ApiKeyResponse( return new ApiKeyResponse(
key.getApiKeyId(), key.getApiKeyId(),
key.getKeyName(), key.getKeyName(),
key.getApiKeyPrefix(), key.getApiKeyPrefix(),
masked, masked,
key.getStatus().name(), key.getStatus().name(),
ownerName,
key.getExpiresAt(), key.getExpiresAt(),
key.getLastUsedAt(), key.getLastUsedAt(),
key.getCreatedAt() key.getCreatedAt()

파일 보기

@ -5,8 +5,10 @@ import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.tenant.entity.SnpTenant; import com.gcsc.connection.tenant.entity.SnpTenant;
import com.gcsc.connection.user.entity.SnpUser; import com.gcsc.connection.user.entity.SnpUser;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.ConstraintMode;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
@ -49,15 +51,15 @@ public class SnpApiRequestLog {
private String requestIp; private String requestIp;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "service_id") @JoinColumn(name = "service_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private SnpService service; private SnpService service;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id") @JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private SnpUser user; private SnpUser user;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "api_key_id") @JoinColumn(name = "api_key_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private SnpApiKey apiKey; private SnpApiKey apiKey;
@Column(name = "response_size") @Column(name = "response_size")
@ -76,7 +78,7 @@ public class SnpApiRequestLog {
private LocalDateTime requestedAt; private LocalDateTime requestedAt;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id") @JoinColumn(name = "tenant_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private SnpTenant tenant; private SnpTenant tenant;
@Builder @Builder

파일 보기

@ -6,6 +6,8 @@ public record CreateServiceApiRequest(
@NotBlank String apiPath, @NotBlank String apiPath,
@NotBlank String apiMethod, @NotBlank String apiMethod,
@NotBlank String apiName, @NotBlank String apiName,
String apiDomain,
String apiSection,
String description String description
) { ) {
} }

파일 보기

@ -10,6 +10,8 @@ public record ServiceApiResponse(
String apiPath, String apiPath,
String apiMethod, String apiMethod,
String apiName, String apiName,
String apiDomain,
String apiSection,
String description, String description,
Boolean isActive, Boolean isActive,
LocalDateTime createdAt, LocalDateTime createdAt,
@ -23,6 +25,8 @@ public record ServiceApiResponse(
a.getApiPath(), a.getApiPath(),
a.getApiMethod(), a.getApiMethod(),
a.getApiName(), a.getApiName(),
a.getApiDomain(),
a.getApiSection(),
a.getDescription(), a.getDescription(),
a.getIsActive(), a.getIsActive(),
a.getCreatedAt(), a.getCreatedAt(),

파일 보기

@ -40,6 +40,12 @@ public class SnpServiceApi extends BaseEntity {
@Column(name = "api_name", length = 200, nullable = false) @Column(name = "api_name", length = 200, nullable = false)
private String apiName; private String apiName;
@Column(name = "api_domain", length = 100)
private String apiDomain;
@Column(name = "api_section", length = 100)
private String apiSection;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
@ -48,19 +54,24 @@ public class SnpServiceApi extends BaseEntity {
@Builder @Builder
public SnpServiceApi(SnpService service, String apiPath, String apiMethod, String apiName, public SnpServiceApi(SnpService service, String apiPath, String apiMethod, String apiName,
String description, Boolean isActive) { String apiDomain, String apiSection, String description, Boolean isActive) {
this.service = service; this.service = service;
this.apiPath = apiPath; this.apiPath = apiPath;
this.apiMethod = apiMethod; this.apiMethod = apiMethod;
this.apiName = apiName; this.apiName = apiName;
this.apiDomain = apiDomain;
this.apiSection = apiSection;
this.description = description; this.description = description;
this.isActive = isActive != null ? isActive : true; this.isActive = isActive != null ? isActive : true;
} }
public void update(String apiPath, String apiMethod, String apiName, String description, Boolean isActive) { public void update(String apiPath, String apiMethod, String apiName,
String apiDomain, String apiSection, String description, Boolean isActive) {
if (apiPath != null) this.apiPath = apiPath; if (apiPath != null) this.apiPath = apiPath;
if (apiMethod != null) this.apiMethod = apiMethod; if (apiMethod != null) this.apiMethod = apiMethod;
if (apiName != null) this.apiName = apiName; if (apiName != null) this.apiName = apiName;
if (apiDomain != null) this.apiDomain = apiDomain;
if (apiSection != null) this.apiSection = apiSection;
if (description != null) this.description = description; if (description != null) this.description = description;
if (isActive != null) this.isActive = isActive; if (isActive != null) this.isActive = isActive;
} }

파일 보기

@ -102,6 +102,8 @@ public class ServiceManagementService {
.apiPath(request.apiPath()) .apiPath(request.apiPath())
.apiMethod(request.apiMethod()) .apiMethod(request.apiMethod())
.apiName(request.apiName()) .apiName(request.apiName())
.apiDomain(request.apiDomain())
.apiSection(request.apiSection())
.description(request.description()) .description(request.description())
.build(); .build();