feat: BY PASS API 등록 프로세스 설계 및 개발 (#63) #108
@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- favicon 변경 (#105)
|
- favicon 변경 (#105)
|
||||||
|
- BY PASS API 등록 프로세스 설계 및 개발 (#63)
|
||||||
|
- 화면에서 API 정보 입력 → Java 코드 자동 생성 (Controller, Service)
|
||||||
|
- 공통 베이스 클래스 (BaseBypassService, BaseBypassController)
|
||||||
|
- JSON 응답 RAW 패스스루 (JsonNode)
|
||||||
|
- 같은 도메인 다중 엔드포인트 지원
|
||||||
|
- Swagger GroupedOpenApi 그룹 분리 및 사용자 설정 반영
|
||||||
|
- SPA 새로고침 오류 수정
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- API 응답 처리 방식에 따른 패키지 분리 (jobs/batch, jobs/web) (#66)
|
- API 응답 처리 방식에 따른 패키지 분리 (jobs/batch, jobs/web) (#66)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const Recollects = lazy(() => import('./pages/Recollects'));
|
|||||||
const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
|
const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
|
||||||
const Schedules = lazy(() => import('./pages/Schedules'));
|
const Schedules = lazy(() => import('./pages/Schedules'));
|
||||||
const Timeline = lazy(() => import('./pages/Timeline'));
|
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||||
|
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
const { toasts, removeToast } = useToastContext();
|
const { toasts, removeToast } = useToastContext();
|
||||||
@ -32,6 +33,7 @@ function AppLayout() {
|
|||||||
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
||||||
<Route path="/schedules" element={<Schedules />} />
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
<Route path="/schedule-timeline" element={<Timeline />} />
|
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||||
|
<Route path="/bypass-config" element={<BypassConfig />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
109
frontend/src/api/bypassApi.ts
Normal file
109
frontend/src/api/bypassApi.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// API 응답 타입
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입 정의
|
||||||
|
export interface BypassParamDto {
|
||||||
|
id?: number;
|
||||||
|
paramName: string;
|
||||||
|
paramType: string; // STRING, INTEGER, LONG, BOOLEAN
|
||||||
|
paramIn: string; // PATH, QUERY, BODY
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
example: string; // Swagger @Parameter example 값
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassConfigRequest {
|
||||||
|
domainName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
description: string;
|
||||||
|
params: BypassParamDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassConfigResponse {
|
||||||
|
id: number;
|
||||||
|
domainName: string;
|
||||||
|
endpointName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
description: string;
|
||||||
|
generated: boolean;
|
||||||
|
generatedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
params: BypassParamDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenerationResult {
|
||||||
|
controllerPath: string;
|
||||||
|
servicePaths: string[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebClientBeanInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASE URL
|
||||||
|
const BASE = '/snp-api/api/bypass-config';
|
||||||
|
|
||||||
|
// 헬퍼 함수 (batchApi.ts 패턴과 동일)
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bypassApi = {
|
||||||
|
getConfigs: () =>
|
||||||
|
fetchJson<ApiResponse<BypassConfigResponse[]>>(BASE),
|
||||||
|
getConfig: (id: number) =>
|
||||||
|
fetchJson<ApiResponse<BypassConfigResponse>>(`${BASE}/${id}`),
|
||||||
|
createConfig: (data: BypassConfigRequest) =>
|
||||||
|
postJson<ApiResponse<BypassConfigResponse>>(BASE, data),
|
||||||
|
updateConfig: (id: number, data: BypassConfigRequest) =>
|
||||||
|
putJson<ApiResponse<BypassConfigResponse>>(`${BASE}/${id}`, data),
|
||||||
|
deleteConfig: (id: number) =>
|
||||||
|
deleteJson<ApiResponse<void>>(`${BASE}/${id}`),
|
||||||
|
generateCode: (id: number, force = false) =>
|
||||||
|
postJson<ApiResponse<CodeGenerationResult>>(`${BASE}/${id}/generate?force=${force}`),
|
||||||
|
getWebclientBeans: () =>
|
||||||
|
fetchJson<ApiResponse<WebClientBeanInfo[]>>(`${BASE}/webclient-beans`),
|
||||||
|
};
|
||||||
@ -8,6 +8,7 @@ const navItems = [
|
|||||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||||
|
{ path: '/bypass-config', label: 'Bypass API', icon: '🔗' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
|||||||
222
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
222
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type {
|
||||||
|
BypassConfigRequest,
|
||||||
|
BypassConfigResponse,
|
||||||
|
BypassParamDto,
|
||||||
|
WebClientBeanInfo,
|
||||||
|
} from '../../api/bypassApi';
|
||||||
|
import BypassStepBasic from './BypassStepBasic';
|
||||||
|
import BypassStepParams from './BypassStepParams';
|
||||||
|
|
||||||
|
interface BypassConfigModalProps {
|
||||||
|
open: boolean;
|
||||||
|
editConfig: BypassConfigResponse | null;
|
||||||
|
webclientBeans: WebClientBeanInfo[];
|
||||||
|
onSave: (data: BypassConfigRequest) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepNumber = 1 | 2;
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<StepNumber, string> = {
|
||||||
|
1: '기본 정보',
|
||||||
|
2: '파라미터',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FORM: Omit<BypassConfigRequest, 'params'> = {
|
||||||
|
domainName: '',
|
||||||
|
displayName: '',
|
||||||
|
webclientBean: '',
|
||||||
|
externalPath: '',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BypassConfigModal({
|
||||||
|
open,
|
||||||
|
editConfig,
|
||||||
|
webclientBeans,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: BypassConfigModalProps) {
|
||||||
|
const [step, setStep] = useState<StepNumber>(1);
|
||||||
|
const [domainName, setDomainName] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [webclientBean, setWebclientBean] = useState('');
|
||||||
|
const [externalPath, setExternalPath] = useState('');
|
||||||
|
const [httpMethod, setHttpMethod] = useState('GET');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [params, setParams] = useState<BypassParamDto[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setStep(1);
|
||||||
|
if (editConfig) {
|
||||||
|
setDomainName(editConfig.domainName);
|
||||||
|
setDisplayName(editConfig.displayName);
|
||||||
|
setWebclientBean(editConfig.webclientBean);
|
||||||
|
setExternalPath(editConfig.externalPath);
|
||||||
|
setHttpMethod(editConfig.httpMethod);
|
||||||
|
setDescription(editConfig.description);
|
||||||
|
setParams(editConfig.params);
|
||||||
|
} else {
|
||||||
|
setDomainName(DEFAULT_FORM.domainName);
|
||||||
|
setDisplayName(DEFAULT_FORM.displayName);
|
||||||
|
setWebclientBean(DEFAULT_FORM.webclientBean);
|
||||||
|
setExternalPath(DEFAULT_FORM.externalPath);
|
||||||
|
setHttpMethod(DEFAULT_FORM.httpMethod);
|
||||||
|
setDescription(DEFAULT_FORM.description);
|
||||||
|
setParams([]);
|
||||||
|
}
|
||||||
|
}, [open, editConfig]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleBasicChange = (field: string, value: string) => {
|
||||||
|
switch (field) {
|
||||||
|
case 'domainName': setDomainName(value); break;
|
||||||
|
case 'displayName': setDisplayName(value); break;
|
||||||
|
case 'webclientBean': setWebclientBean(value); break;
|
||||||
|
case 'externalPath': setExternalPath(value); break;
|
||||||
|
case 'httpMethod': setHttpMethod(value); break;
|
||||||
|
case 'description': setDescription(value); break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
domainName,
|
||||||
|
displayName,
|
||||||
|
webclientBean,
|
||||||
|
externalPath,
|
||||||
|
httpMethod,
|
||||||
|
description,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps: StepNumber[] = [1, 2];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl w-full max-w-3xl mx-4 flex flex-col max-h-[90vh]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-6 pt-6 pb-4 border-b border-wing-border shrink-0">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
{editConfig ? 'Bypass API 수정' : 'Bypass API 등록'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 스텝 인디케이터 */}
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
{steps.map((s, idx) => (
|
||||||
|
<div key={s} className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'w-7 h-7 rounded-full flex items-center justify-center text-sm font-semibold transition-colors',
|
||||||
|
step === s
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: step > s
|
||||||
|
? 'bg-wing-accent/30 text-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'text-sm font-medium',
|
||||||
|
step === s ? 'text-wing-text' : 'text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[s]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<div className="w-8 h-px bg-wing-border mx-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="px-6 py-5 overflow-y-auto flex-1">
|
||||||
|
{step === 1 && (
|
||||||
|
<BypassStepBasic
|
||||||
|
domainName={domainName}
|
||||||
|
displayName={displayName}
|
||||||
|
webclientBean={webclientBean}
|
||||||
|
externalPath={externalPath}
|
||||||
|
httpMethod={httpMethod}
|
||||||
|
description={description}
|
||||||
|
webclientBeans={webclientBeans}
|
||||||
|
isEdit={editConfig !== null}
|
||||||
|
onChange={handleBasicChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<BypassStepParams params={params} onChange={setParams} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="px-6 py-4 border-t border-wing-border flex justify-between shrink-0">
|
||||||
|
<div>
|
||||||
|
{step > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => (s - 1) as StepNumber)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
← 이전
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{step === 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step < 2 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => (s + 1) as StepNumber)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
다음 →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
142
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import type { WebClientBeanInfo } from '../../api/bypassApi';
|
||||||
|
|
||||||
|
interface BypassStepBasicProps {
|
||||||
|
domainName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
description: string;
|
||||||
|
webclientBeans: WebClientBeanInfo[];
|
||||||
|
isEdit: boolean;
|
||||||
|
onChange: (field: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassStepBasic({
|
||||||
|
domainName,
|
||||||
|
displayName,
|
||||||
|
webclientBean,
|
||||||
|
externalPath,
|
||||||
|
httpMethod,
|
||||||
|
description,
|
||||||
|
webclientBeans,
|
||||||
|
isEdit,
|
||||||
|
onChange,
|
||||||
|
}: BypassStepBasicProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
BYPASS API의 기본 정보를 입력하세요. 도메인명을 기반으로 코드가 생성됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 도메인명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
도메인명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domainName}
|
||||||
|
onChange={(e) => onChange('domainName', e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="예: riskByImo"
|
||||||
|
pattern="[a-zA-Z][a-zA-Z0-9]*"
|
||||||
|
className={[
|
||||||
|
'w-full px-3 py-2 text-sm rounded-lg border',
|
||||||
|
'border-wing-border bg-wing-card text-wing-text',
|
||||||
|
'placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50',
|
||||||
|
isEdit ? 'opacity-50 cursor-not-allowed' : '',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-wing-muted">영문 소문자/숫자 조합 (수정 불가)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
표시명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => onChange('displayName', e.target.value)}
|
||||||
|
placeholder="예: IMO 기반 리스크 조회"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WebClient */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
WebClient Bean <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={webclientBean}
|
||||||
|
onChange={(e) => onChange('webclientBean', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
{webclientBeans.map((bean) => (
|
||||||
|
<option key={bean.name} value={bean.name}>
|
||||||
|
{bean.description || bean.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 API 경로 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
외부 API 경로 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={externalPath}
|
||||||
|
onChange={(e) => onChange('externalPath', e.target.value)}
|
||||||
|
placeholder="/RiskAndCompliance/RisksByImos"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP 메서드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
HTTP 메서드 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['GET', 'POST'].map((method) => (
|
||||||
|
<button
|
||||||
|
key={method}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('httpMethod', method)}
|
||||||
|
className={[
|
||||||
|
'flex-1 py-2 text-sm font-medium rounded-lg border transition-colors',
|
||||||
|
httpMethod === method
|
||||||
|
? 'bg-wing-accent text-white border-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:bg-wing-hover',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{method}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="이 API에 대한 설명을 입력하세요"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
157
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import type { BypassParamDto } from '../../api/bypassApi';
|
||||||
|
|
||||||
|
interface BypassStepParamsProps {
|
||||||
|
params: BypassParamDto[];
|
||||||
|
onChange: (params: BypassParamDto[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARAM_TYPES = ['STRING', 'INTEGER', 'LONG', 'BOOLEAN'];
|
||||||
|
const PARAM_IN_OPTIONS = ['PATH', 'QUERY', 'BODY'];
|
||||||
|
|
||||||
|
function createEmptyParam(sortOrder: number): BypassParamDto {
|
||||||
|
return {
|
||||||
|
paramName: '',
|
||||||
|
paramType: 'STRING',
|
||||||
|
paramIn: 'QUERY',
|
||||||
|
required: false,
|
||||||
|
description: '',
|
||||||
|
example: '',
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassStepParams({ params, onChange }: BypassStepParamsProps) {
|
||||||
|
const handleAdd = () => {
|
||||||
|
onChange([...params, createEmptyParam(params.length)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const updated = params
|
||||||
|
.filter((_, i) => i !== index)
|
||||||
|
.map((p, i) => ({ ...p, sortOrder: i }));
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, field: keyof BypassParamDto, value: string | boolean | number) => {
|
||||||
|
const updated = params.map((p, i) =>
|
||||||
|
i === index ? { ...p, [field]: value } : p,
|
||||||
|
);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
외부 API 호출에 필요한 파라미터를 정의하세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{params.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-wing-muted border border-dashed border-wing-border rounded-lg bg-wing-card">
|
||||||
|
파라미터가 없습니다. 추가 버튼을 클릭하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border">
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[120px]">이름</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[110px]">타입</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[100px]">위치</th>
|
||||||
|
<th className="pb-2 text-center font-medium text-wing-muted pr-3 w-14">필수</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3">설명</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[120px]">Example</th>
|
||||||
|
<th className="pb-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{params.map((param, index) => (
|
||||||
|
<tr key={index} className="group">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramName}
|
||||||
|
onChange={(e) => handleChange(index, 'paramName', e.target.value)}
|
||||||
|
placeholder="paramName"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
value={param.paramType}
|
||||||
|
onChange={(e) => handleChange(index, 'paramType', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
{PARAM_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
value={param.paramIn}
|
||||||
|
onChange={(e) => handleChange(index, 'paramIn', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
{PARAM_IN_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={param.required}
|
||||||
|
onChange={(e) => handleChange(index, 'required', e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-wing-border text-wing-accent focus:ring-wing-accent/50 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.description}
|
||||||
|
onChange={(e) => handleChange(index, 'description', e.target.value)}
|
||||||
|
placeholder="파라미터 설명"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.example}
|
||||||
|
onChange={(e) => handleChange(index, 'example', e.target.value)}
|
||||||
|
placeholder="예: 9876543"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(index)}
|
||||||
|
className="p-1.5 text-wing-muted hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-wing-accent border border-wing-accent/30 rounded-lg hover:bg-wing-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
파라미터 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
507
frontend/src/pages/BypassConfig.tsx
Normal file
507
frontend/src/pages/BypassConfig.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
bypassApi,
|
||||||
|
type BypassConfigRequest,
|
||||||
|
type BypassConfigResponse,
|
||||||
|
type CodeGenerationResult,
|
||||||
|
type WebClientBeanInfo,
|
||||||
|
} from '../api/bypassApi';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import BypassConfigModal from '../components/bypass/BypassConfigModal';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import InfoModal from '../components/InfoModal';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
interface ConfirmAction {
|
||||||
|
type: 'delete' | 'generate';
|
||||||
|
config: BypassConfigResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'card' | 'table';
|
||||||
|
|
||||||
|
const HTTP_METHOD_COLORS: Record<string, string> = {
|
||||||
|
GET: 'bg-emerald-100 text-emerald-700',
|
||||||
|
POST: 'bg-blue-100 text-blue-700',
|
||||||
|
PUT: 'bg-amber-100 text-amber-700',
|
||||||
|
DELETE: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BypassConfig() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<BypassConfigResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [webclientBeans, setWebclientBeans] = useState<WebClientBeanInfo[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState('');
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editConfig, setEditConfig] = useState<BypassConfigResponse | null>(null);
|
||||||
|
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [generationResult, setGenerationResult] = useState<CodeGenerationResult | null>(null);
|
||||||
|
|
||||||
|
const loadConfigs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await bypassApi.getConfigs();
|
||||||
|
setConfigs(res.data ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Bypass API 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigs();
|
||||||
|
bypassApi.getWebclientBeans()
|
||||||
|
.then((res) => setWebclientBeans(res.data ?? []))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
}, [loadConfigs]);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditConfig(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (config: BypassConfigResponse) => {
|
||||||
|
setEditConfig(config);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (data: BypassConfigRequest) => {
|
||||||
|
if (editConfig) {
|
||||||
|
await bypassApi.updateConfig(editConfig.id, data);
|
||||||
|
showToast('Bypass API가 수정되었습니다.', 'success');
|
||||||
|
} else {
|
||||||
|
await bypassApi.createConfig(data);
|
||||||
|
showToast('Bypass API가 등록되었습니다.', 'success');
|
||||||
|
}
|
||||||
|
await loadConfigs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!confirmAction || confirmAction.type !== 'delete') return;
|
||||||
|
try {
|
||||||
|
await bypassApi.deleteConfig(confirmAction.config.id);
|
||||||
|
showToast('Bypass API가 삭제되었습니다.', 'success');
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('삭제 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setConfirmAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateConfirm = async () => {
|
||||||
|
if (!confirmAction || confirmAction.type !== 'generate') return;
|
||||||
|
const targetConfig = confirmAction.config;
|
||||||
|
setConfirmAction(null);
|
||||||
|
try {
|
||||||
|
const res = await bypassApi.generateCode(targetConfig.id, targetConfig.generated);
|
||||||
|
setGenerationResult(res.data);
|
||||||
|
showToast('코드가 생성되었습니다.', 'success');
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('코드 생성 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const domainNames = useMemo(() => {
|
||||||
|
const names = [...new Set(configs.map((c) => c.domainName))];
|
||||||
|
return names.sort();
|
||||||
|
}, [configs]);
|
||||||
|
|
||||||
|
const filteredConfigs = useMemo(() => {
|
||||||
|
return configs.filter((c) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.displayName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
|
||||||
|
return matchesSearch && matchesDomain;
|
||||||
|
});
|
||||||
|
}, [configs, searchTerm, selectedDomain]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">Bypass API 관리</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
외부 Maritime API를 직접 프록시하는 Bypass API를 등록하고 코드를 생성합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+ 새 API 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 + 뷰 전환 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="도메인명, 표시명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none bg-wing-surface text-wing-text"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도메인 드롭다운 필터 */}
|
||||||
|
<select
|
||||||
|
value={selectedDomain}
|
||||||
|
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface text-wing-text"
|
||||||
|
>
|
||||||
|
<option value="">전체 도메인</option>
|
||||||
|
{domainNames.map((name) => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* 뷰 전환 토글 */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="테이블 보기"
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode('card')}
|
||||||
|
title="카드 보기"
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(searchTerm || selectedDomain) && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
{filteredConfigs.length}개 API 검색됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{configs.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||||
|
<p className="text-base font-medium mb-1">등록된 BYPASS API가 없습니다.</p>
|
||||||
|
<p className="text-sm">위 버튼을 눌러 새 API를 등록하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : filteredConfigs.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||||
|
<p className="text-base font-medium mb-1">검색 결과가 없습니다.</p>
|
||||||
|
<p className="text-sm">다른 검색어를 사용해 보세요.</p>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
/* 카드 뷰 */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredConfigs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="bg-wing-card border border-wing-border rounded-xl p-5 flex flex-col gap-3 hover:border-wing-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-wing-text truncate">{config.displayName}</p>
|
||||||
|
<p className="text-xs text-wing-muted font-mono mt-0.5">{config.domainName}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'shrink-0 px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
config.generated
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.generated ? '생성 완료' : '미생성'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 정보 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-1.5 py-0.5 text-xs font-bold rounded',
|
||||||
|
HTTP_METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-wing-muted font-mono truncate">
|
||||||
|
{config.externalPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
<span className="font-medium text-wing-text">WebClient:</span>{' '}
|
||||||
|
{config.webclientBean}
|
||||||
|
</p>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 액션 */}
|
||||||
|
<div className="flex gap-2 pt-1 border-t border-wing-border mt-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
className="flex-1 py-1.5 text-xs font-medium text-wing-text bg-wing-surface hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'generate', config })}
|
||||||
|
className="flex-1 py-1.5 text-xs font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', config })}
|
||||||
|
className="py-1.5 px-3 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 테이블 뷰 */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
도메인명
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
표시명
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
HTTP 메서드
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
WebClient
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
외부 경로
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
생성 상태
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
등록일
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{filteredConfigs.map((config) => (
|
||||||
|
<tr key={config.id} className="hover:bg-wing-hover transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-text">
|
||||||
|
{config.domainName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text">
|
||||||
|
{config.displayName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-2 py-0.5 text-xs font-bold rounded',
|
||||||
|
HTTP_METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{config.webclientBean}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[200px] truncate">
|
||||||
|
{config.externalPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
config.generated
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.generated ? '생성 완료' : '미생성'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
||||||
|
{config.createdAt
|
||||||
|
? new Date(config.createdAt).toLocaleDateString('ko-KR')
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'generate', config })}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', config })}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<BypassConfigModal
|
||||||
|
open={modalOpen}
|
||||||
|
editConfig={editConfig}
|
||||||
|
webclientBeans={webclientBeans}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmAction?.type === 'delete'}
|
||||||
|
title="삭제 확인"
|
||||||
|
message={`"${confirmAction?.config.displayName}"을(를) 정말 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.`}
|
||||||
|
confirmLabel="삭제"
|
||||||
|
confirmColor="bg-red-500 hover:bg-red-600"
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 코드 생성 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmAction?.type === 'generate'}
|
||||||
|
title={confirmAction?.config.generated ? '코드 재생성 확인' : '코드 생성 확인'}
|
||||||
|
message={
|
||||||
|
confirmAction?.config.generated
|
||||||
|
? `"${confirmAction?.config.displayName}" 코드를 재생성합니다.\n기존 생성된 파일이 덮어씌워집니다. 계속하시겠습니까?`
|
||||||
|
: `"${confirmAction?.config.displayName}" 코드를 생성합니다.\n계속하시겠습니까?`
|
||||||
|
}
|
||||||
|
confirmLabel={confirmAction?.config.generated ? '재생성' : '생성'}
|
||||||
|
onConfirm={handleGenerateConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 코드 생성 결과 모달 */}
|
||||||
|
<InfoModal
|
||||||
|
open={generationResult !== null}
|
||||||
|
title="코드 생성 완료"
|
||||||
|
onClose={() => setGenerationResult(null)}
|
||||||
|
>
|
||||||
|
{generationResult && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-wing-text">{generationResult.message}</p>
|
||||||
|
<div className="bg-wing-card rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-wing-text mb-1">생성된 파일</p>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<span className="w-20 font-medium text-wing-accent shrink-0">Controller</span>
|
||||||
|
<span className="font-mono text-wing-muted break-all">{generationResult.controllerPath}</span>
|
||||||
|
</div>
|
||||||
|
{generationResult.servicePaths.map((path, idx) => (
|
||||||
|
<div key={`service-${idx}`} className="flex gap-2 text-xs">
|
||||||
|
<span className="w-20 font-medium text-wing-accent shrink-0">
|
||||||
|
Service {generationResult.servicePaths.length > 1 ? idx + 1 : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-wing-muted break-all">{path}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 bg-amber-50 text-amber-700 rounded-lg p-3 text-xs">
|
||||||
|
<span className="shrink-0">⚠</span>
|
||||||
|
<span>서버를 재시작하면 새 API가 활성화됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.snp.batch.common.web.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseBypassController {
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<ApiResponse<T>> execute(Supplier<T> action) {
|
||||||
|
try {
|
||||||
|
T result = action.get();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
log.error("외부 API 호출 실패 - status: {}, body: {}",
|
||||||
|
e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
return ResponseEntity.status(e.getStatusCode())
|
||||||
|
.body(ApiResponse.error("외부 API 호출 실패: " + e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API 처리 중 오류", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error("처리 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.snp.batch.common.web.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.util.UriBuilder;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseBypassService<T> {
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final String apiPath;
|
||||||
|
private final String displayName;
|
||||||
|
private final ParameterizedTypeReference<List<T>> listTypeRef;
|
||||||
|
private final ParameterizedTypeReference<T> singleTypeRef;
|
||||||
|
|
||||||
|
protected BaseBypassService(WebClient webClient, String apiPath, String displayName,
|
||||||
|
ParameterizedTypeReference<List<T>> listTypeRef,
|
||||||
|
ParameterizedTypeReference<T> singleTypeRef) {
|
||||||
|
this.webClient = webClient;
|
||||||
|
this.apiPath = apiPath;
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.listTypeRef = listTypeRef;
|
||||||
|
this.singleTypeRef = singleTypeRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<T> fetchGetList(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출", displayName);
|
||||||
|
List<T> response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(listTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null || response.isEmpty()) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 - 건수: {}", displayName, response.size());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T fetchGetOne(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출 (단건)", displayName);
|
||||||
|
T response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(singleTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 (단건)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<T> fetchPostList(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출", displayName);
|
||||||
|
List<T> response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(listTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null || response.isEmpty()) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 - 건수: {}", displayName, response.size());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T fetchPostOne(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출 (단건)", displayName);
|
||||||
|
T response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(singleTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 (단건)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAW GET 요청 → JsonNode 반환 (응답 구조 그대로 패스스루)
|
||||||
|
*/
|
||||||
|
protected JsonNode fetchRawGet(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출 (RAW)", displayName);
|
||||||
|
JsonNode response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(JsonNode.class)
|
||||||
|
.block();
|
||||||
|
log.info("{} API 응답 완료 (RAW)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAW POST 요청 → JsonNode 반환 (응답 구조 그대로 패스스루)
|
||||||
|
*/
|
||||||
|
protected JsonNode fetchRawPost(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출 (RAW)", displayName);
|
||||||
|
JsonNode response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(JsonNode.class)
|
||||||
|
.block();
|
||||||
|
log.info("{} API 응답 완료 (RAW)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getApiPath() {
|
||||||
|
return apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import io.swagger.v3.oas.models.info.Contact;
|
|||||||
import io.swagger.v3.oas.models.info.Info;
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
import io.swagger.v3.oas.models.info.License;
|
import io.swagger.v3.oas.models.info.License;
|
||||||
import io.swagger.v3.oas.models.servers.Server;
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import org.springdoc.core.models.GroupedOpenApi;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@ -33,6 +34,31 @@ public class SwaggerConfig {
|
|||||||
@Value("${server.servlet.context-path:}")
|
@Value("${server.servlet.context-path:}")
|
||||||
private String contextPath;
|
private String contextPath;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi batchManagementApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("1. Batch Management")
|
||||||
|
.pathsToMatch("/api/batch/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi bypassConfigApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("2. Bypass Config")
|
||||||
|
.pathsToMatch("/api/bypass-config/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi bypassApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("3. Bypass API")
|
||||||
|
.pathsToMatch("/api/**")
|
||||||
|
.pathsToExclude("/api/batch/**", "/api/bypass-config/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public OpenAPI openAPI() {
|
public OpenAPI openAPI() {
|
||||||
return new OpenAPI()
|
return new OpenAPI()
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.global.dto.BypassConfigRequest;
|
||||||
|
import com.snp.batch.global.dto.BypassConfigResponse;
|
||||||
|
import com.snp.batch.global.dto.CodeGenerationResult;
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import com.snp.batch.global.repository.BypassApiConfigRepository;
|
||||||
|
import com.snp.batch.service.BypassCodeGenerator;
|
||||||
|
import com.snp.batch.service.BypassConfigService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 관리 및 코드 생성 컨트롤러
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/bypass-config")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Bypass Config", description = "BYPASS API 설정 관리 및 코드 생성")
|
||||||
|
public class BypassConfigController {
|
||||||
|
|
||||||
|
private final BypassConfigService bypassConfigService;
|
||||||
|
private final BypassCodeGenerator bypassCodeGenerator;
|
||||||
|
private final BypassApiConfigRepository configRepository;
|
||||||
|
|
||||||
|
@Operation(summary = "설정 목록 조회")
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<BypassConfigResponse>>> getConfigs() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.getConfigs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 상세 조회")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> getConfig(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.getConfig(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 등록")
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> createConfig(
|
||||||
|
@RequestBody BypassConfigRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.createConfig(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 수정")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> updateConfig(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody BypassConfigRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.updateConfig(id, request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 삭제")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteConfig(@PathVariable Long id) {
|
||||||
|
bypassConfigService.deleteConfig(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("삭제 완료", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "코드 생성",
|
||||||
|
description = "등록된 설정의 도메인 전체를 기반으로 Controller, Service 소스 코드를 생성합니다. 같은 도메인의 모든 설정을 하나의 Controller로 합칩니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/{id}/generate")
|
||||||
|
public ResponseEntity<ApiResponse<CodeGenerationResult>> generateCode(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "false") boolean force) {
|
||||||
|
try {
|
||||||
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||||
|
|
||||||
|
List<BypassApiConfig> domainConfigs = configRepository.findByDomainNameOrderById(config.getDomainName());
|
||||||
|
|
||||||
|
CodeGenerationResult result = bypassCodeGenerator.generate(domainConfigs, force);
|
||||||
|
|
||||||
|
domainConfigs.forEach(c -> bypassConfigService.markAsGenerated(c.getId()));
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("코드 생성 실패", e);
|
||||||
|
return ResponseEntity.internalServerError().body(ApiResponse.error("코드 생성 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "WebClient 빈 목록", description = "사용 가능한 WebClient 빈 이름 목록을 반환합니다.")
|
||||||
|
@GetMapping("/webclient-beans")
|
||||||
|
public ResponseEntity<ApiResponse<List<Map<String, String>>>> getWebclientBeans() {
|
||||||
|
List<Map<String, String>> beans = List.of(
|
||||||
|
Map.of("name", "maritimeApiWebClient", "description", "Ship API (shipsapi.maritime.spglobal.com)"),
|
||||||
|
Map.of("name", "maritimeAisApiWebClient", "description", "AIS API (aisapi.maritime.spglobal.com)"),
|
||||||
|
Map.of("name", "maritimeServiceApiWebClient", "description", "Web Service API (webservices.maritime.spglobal.com)")
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(beans));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,8 +15,10 @@ public class WebViewController {
|
|||||||
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||||
"/recollects", "/recollects/{id:\\d+}",
|
"/recollects", "/recollects/{id:\\d+}",
|
||||||
"/schedules", "/schedule-timeline", "/monitoring",
|
"/schedules", "/schedule-timeline", "/monitoring",
|
||||||
|
"/bypass-config",
|
||||||
"/jobs/**", "/executions/**", "/recollects/**",
|
"/jobs/**", "/executions/**", "/recollects/**",
|
||||||
"/schedules/**", "/schedule-timeline/**", "/monitoring/**"})
|
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
|
||||||
|
"/bypass-config/**"})
|
||||||
public String forward() {
|
public String forward() {
|
||||||
return "forward:/index.html";
|
return "forward:/index.html";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 등록/수정 요청 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassConfigRequest {
|
||||||
|
|
||||||
|
/** 도메인명 (패키지명/URL 경로) */
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/** WebClient 빈 이름 */
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/** 외부 API 경로 */
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/** HTTP 메서드 */
|
||||||
|
private String httpMethod;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 조회 응답 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassConfigResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 도메인명 (패키지명/URL 경로) */
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/** 엔드포인트명 (externalPath 마지막 세그먼트) */
|
||||||
|
private String endpointName;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/** WebClient 빈 이름 */
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/** 외부 API 경로 */
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/** HTTP 메서드 */
|
||||||
|
private String httpMethod;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 코드 생성 완료 여부 */
|
||||||
|
private Boolean generated;
|
||||||
|
|
||||||
|
/** 코드 생성 일시 */
|
||||||
|
private LocalDateTime generatedAt;
|
||||||
|
|
||||||
|
/** 생성 일시 */
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** 수정 일시 */
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
}
|
||||||
39
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
39
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 파라미터 정보 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassParamDto {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 파라미터 이름 */
|
||||||
|
private String paramName;
|
||||||
|
|
||||||
|
/** 파라미터 타입 (STRING, INTEGER, LONG, BOOLEAN) */
|
||||||
|
private String paramType;
|
||||||
|
|
||||||
|
/** 파라미터 위치 (PATH, QUERY, BODY) */
|
||||||
|
private String paramIn;
|
||||||
|
|
||||||
|
/** 필수 여부 */
|
||||||
|
private Boolean required;
|
||||||
|
|
||||||
|
/** 파라미터 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 정렬 순서 */
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
/** Swagger @Parameter example 값 */
|
||||||
|
private String example;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 자동 생성 결과 DTO
|
||||||
|
* 같은 도메인에 N개의 엔드포인트를 지원하므로 Service/DTO는 목록으로 반환
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CodeGenerationResult {
|
||||||
|
|
||||||
|
/** 생성된 Controller 파일 경로 */
|
||||||
|
private String controllerPath;
|
||||||
|
|
||||||
|
/** 생성된 Service 파일 경로 목록 (엔드포인트별) */
|
||||||
|
private List<String> servicePaths;
|
||||||
|
|
||||||
|
/** 생성된 DTO 파일 경로 목록 (엔드포인트별) */
|
||||||
|
private List<String> dtoPaths;
|
||||||
|
|
||||||
|
/** 결과 메시지 */
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
153
src/main/java/com/snp/batch/global/model/BypassApiConfig.java
Normal file
153
src/main/java/com/snp/batch/global/model/BypassApiConfig.java
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 정보를 저장하는 엔티티
|
||||||
|
* 외부 API를 동적으로 프록시하기 위한 설정 메타데이터
|
||||||
|
*
|
||||||
|
* JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "bypass_api_config",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_bypass_config_domain_endpoint", columnNames = {"domain_name", "endpoint_name"})
|
||||||
|
},
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_bypass_config_domain_name", columnList = "domain_name")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassApiConfig {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명 (패키지명/URL 경로)
|
||||||
|
* 예: "ship-info", "port-data"
|
||||||
|
*/
|
||||||
|
@Column(name = "domain_name", nullable = false, length = 50)
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔드포인트명 (externalPath의 마지막 세그먼트에서 자동 추출)
|
||||||
|
* 예: "CompliancesByImos", "CompanyCompliancesByImos"
|
||||||
|
*/
|
||||||
|
@Column(name = "endpoint_name", nullable = false, length = 100)
|
||||||
|
private String endpointName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시명
|
||||||
|
* 예: "선박 정보 API", "항만 데이터 API"
|
||||||
|
*/
|
||||||
|
@Column(name = "display_name", nullable = false, length = 100)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient 빈 이름
|
||||||
|
* 예: "maritimeWebClient", "portWebClient"
|
||||||
|
*/
|
||||||
|
@Column(name = "webclient_bean", nullable = false, length = 100)
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 경로
|
||||||
|
* 예: "/api/v1/ships/{imoNumber}"
|
||||||
|
*/
|
||||||
|
@Column(name = "external_path", nullable = false, length = 500)
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 메서드
|
||||||
|
* 예: "GET", "POST"
|
||||||
|
*/
|
||||||
|
@Column(name = "http_method", nullable = false, length = 10)
|
||||||
|
@Builder.Default
|
||||||
|
private String httpMethod = "GET";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설명
|
||||||
|
*/
|
||||||
|
@Column(name = "description", length = 1000)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 생성 완료 여부
|
||||||
|
*/
|
||||||
|
@Column(name = "generated", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean generated = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 생성 일시
|
||||||
|
*/
|
||||||
|
@Column(name = "generated_at")
|
||||||
|
private LocalDateTime generatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 일시 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 일시 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 파라미터 목록
|
||||||
|
*/
|
||||||
|
@OneToMany(mappedBy = "config", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@Builder.Default
|
||||||
|
private List<BypassApiParam> params = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 저장 전 자동 호출 (INSERT 시)
|
||||||
|
* endpointName이 null이면 externalPath에서 자동 추출 (마이그레이션 대응)
|
||||||
|
*/
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
if (this.endpointName == null || this.endpointName.isEmpty()) {
|
||||||
|
this.endpointName = extractEndpointName(this.externalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 업데이트 전 자동 호출 (UPDATE 시)
|
||||||
|
* endpointName이 null이면 externalPath에서 자동 추출 (마이그레이션 대응)
|
||||||
|
*/
|
||||||
|
private static String extractEndpointName(String externalPath) {
|
||||||
|
if (externalPath == null || externalPath.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String[] segments = externalPath.split("/");
|
||||||
|
return segments[segments.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 업데이트 전 자동 호출 (UPDATE 시)
|
||||||
|
*/
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
if (this.endpointName == null || this.endpointName.isEmpty()) {
|
||||||
|
this.endpointName = extractEndpointName(this.externalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/main/java/com/snp/batch/global/model/BypassApiParam.java
Normal file
83
src/main/java/com/snp/batch/global/model/BypassApiParam.java
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 파라미터 정보를 저장하는 엔티티
|
||||||
|
* BypassApiConfig에 종속되며, API 호출 시 사용할 파라미터 메타데이터를 정의
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "bypass_api_param",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"config_id", "param_name"})
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassApiParam {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연관된 BYPASS API 설정
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "config_id", nullable = false)
|
||||||
|
private BypassApiConfig config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 이름
|
||||||
|
* 예: "imoNumber", "mmsi", "startDate"
|
||||||
|
*/
|
||||||
|
@Column(name = "param_name", nullable = false, length = 100)
|
||||||
|
private String paramName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 타입
|
||||||
|
* 예: "STRING", "INTEGER", "LONG", "BOOLEAN"
|
||||||
|
*/
|
||||||
|
@Column(name = "param_type", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String paramType = "STRING";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 위치
|
||||||
|
* 예: "PATH", "QUERY", "BODY"
|
||||||
|
*/
|
||||||
|
@Column(name = "param_in", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String paramIn = "QUERY";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 여부
|
||||||
|
*/
|
||||||
|
@Column(name = "required", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean required = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 설명
|
||||||
|
*/
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 순서
|
||||||
|
*/
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Integer sortOrder = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger @Parameter example 값
|
||||||
|
*/
|
||||||
|
@Column(name = "example", length = 200)
|
||||||
|
private String example;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BypassApiConfig Repository
|
||||||
|
* JPA Repository 방식으로 자동 구현
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BypassApiConfigRepository extends JpaRepository<BypassApiConfig, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명으로 BYPASS API 설정 단건 조회 (하위 호환)
|
||||||
|
*/
|
||||||
|
Optional<BypassApiConfig> findByDomainName(String domainName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명으로 BYPASS API 설정 목록 조회 (ID 순)
|
||||||
|
*/
|
||||||
|
List<BypassApiConfig> findByDomainNameOrderById(String domainName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명 + 엔드포인트명 복합 유니크 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByDomainNameAndEndpointName(String domainName, String endpointName);
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BypassApiParam;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BypassApiParam Repository
|
||||||
|
* JPA Repository 방식으로 자동 구현
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BypassApiParamRepository extends JpaRepository<BypassApiParam, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 ID로 파라미터 목록 조회 (정렬 순서 기준 오름차순)
|
||||||
|
*/
|
||||||
|
List<BypassApiParam> findByConfigIdOrderBySortOrder(Long configId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 ID로 파라미터 전체 삭제
|
||||||
|
*/
|
||||||
|
void deleteByConfigId(Long configId);
|
||||||
|
}
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package com.snp.batch.jobs.web.risk.controller;
|
|
||||||
|
|
||||||
import com.snp.batch.common.web.ApiResponse;
|
|
||||||
import com.snp.batch.jobs.batch.risk.dto.RiskDto;
|
|
||||||
import com.snp.batch.jobs.web.risk.service.RiskBypassService;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Risk 상세 조회 bypass API
|
|
||||||
* S&P Maritime API에서 Risk 데이터를 실시간 조회하여 그대로 반환
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/risk")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Tag(name = "Risk", description = "선박 Risk 상세 정보 bypass API")
|
|
||||||
public class RiskController {
|
|
||||||
|
|
||||||
private final RiskBypassService riskBypassService;
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "IMO 기반 Risk 상세 조회",
|
|
||||||
description = "S&P API에 IMO 번호로 Risk 상세 데이터를 요청하고 응답을 그대로 반환합니다."
|
|
||||||
)
|
|
||||||
@GetMapping("/{imo}")
|
|
||||||
public ResponseEntity<ApiResponse<List<RiskDto>>> getRiskDetailByImo(
|
|
||||||
@Parameter(description = "IMO 번호", example = "9321483")
|
|
||||||
@PathVariable String imo
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
List<RiskDto> riskData = riskBypassService.getRiskDetailByImo(imo);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(riskData));
|
|
||||||
} catch (WebClientResponseException e) {
|
|
||||||
log.error("S&P Risk API 호출 실패 - IMO: {}, status: {}, body: {}",
|
|
||||||
imo, e.getStatusCode(), e.getResponseBodyAsString());
|
|
||||||
return ResponseEntity.status(e.getStatusCode())
|
|
||||||
.body(ApiResponse.error("S&P API 호출 실패: " + e.getMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Risk 상세 조회 중 오류 - IMO: {}", imo, e);
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(ApiResponse.error("Risk 조회 실패: " + e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package com.snp.batch.jobs.web.risk.service;
|
|
||||||
|
|
||||||
import com.snp.batch.jobs.batch.risk.dto.RiskDto;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* S&P Risk API bypass 서비스
|
|
||||||
* 외부 Maritime API에서 Risk 상세 데이터를 실시간 조회하여 그대로 반환
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class RiskBypassService {
|
|
||||||
|
|
||||||
private static final String RISK_API_PATH = "/RiskAndCompliance/RisksByImos";
|
|
||||||
|
|
||||||
private final WebClient webClient;
|
|
||||||
|
|
||||||
public RiskBypassService(
|
|
||||||
@Qualifier("maritimeServiceApiWebClient") WebClient webClient
|
|
||||||
) {
|
|
||||||
this.webClient = webClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IMO 번호로 S&P Risk 상세 데이터를 조회합니다.
|
|
||||||
*
|
|
||||||
* @param imo IMO 번호
|
|
||||||
* @return Risk 상세 데이터 목록
|
|
||||||
*/
|
|
||||||
public List<RiskDto> getRiskDetailByImo(String imo) {
|
|
||||||
log.info("S&P Risk API 호출 - IMO: {}", imo);
|
|
||||||
|
|
||||||
List<RiskDto> response = webClient.get()
|
|
||||||
.uri(uriBuilder -> uriBuilder
|
|
||||||
.path(RISK_API_PATH)
|
|
||||||
.queryParam("imos", imo)
|
|
||||||
.build())
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(new ParameterizedTypeReference<List<RiskDto>>() {})
|
|
||||||
.block();
|
|
||||||
|
|
||||||
if (response == null || response.isEmpty()) {
|
|
||||||
log.warn("S&P Risk API 응답 없음 - IMO: {}", imo);
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("S&P Risk API 응답 완료 - IMO: {}, 건수: {}", imo, response.size());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
386
src/main/java/com/snp/batch/service/BypassCodeGenerator.java
Normal file
386
src/main/java/com/snp/batch/service/BypassCodeGenerator.java
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
package com.snp.batch.service;
|
||||||
|
|
||||||
|
import com.snp.batch.global.dto.CodeGenerationResult;
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import com.snp.batch.global.model.BypassApiParam;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API Java 소스 코드를 자동 생성하는 서비스.
|
||||||
|
* 모든 API는 RAW 모드(JsonNode 패스스루)로 생성됩니다.
|
||||||
|
* 같은 도메인의 N개 설정을 받아 N개의 Service와 1개의 Controller를 생성합니다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class BypassCodeGenerator {
|
||||||
|
|
||||||
|
private static final String BASE_PACKAGE = "com.snp.batch.jobs.web";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 도메인의 BYPASS API 코드를 생성합니다.
|
||||||
|
* 엔드포인트별 Service를 각각 생성하고, Controller 1개에 모든 엔드포인트 메서드를 합칩니다.
|
||||||
|
* 모든 응답은 JsonNode로 패스스루됩니다 (DTO 없음).
|
||||||
|
*/
|
||||||
|
public CodeGenerationResult generate(List<BypassApiConfig> configs, boolean force) {
|
||||||
|
if (configs == null || configs.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("생성할 설정이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String projectRoot = System.getProperty("user.dir");
|
||||||
|
String domain = configs.get(0).getDomainName();
|
||||||
|
String basePath = projectRoot + "/src/main/java/com/snp/batch/jobs/web/" + domain;
|
||||||
|
|
||||||
|
List<String> servicePaths = new ArrayList<>();
|
||||||
|
|
||||||
|
for (BypassApiConfig config : configs) {
|
||||||
|
String endpointName = config.getEndpointName();
|
||||||
|
String servicePath = basePath + "/service/" + endpointName + "Service.java";
|
||||||
|
|
||||||
|
if (!force && Files.exists(Path.of(servicePath))) {
|
||||||
|
log.info("Service 파일 이미 존재, 스킵: {}", servicePath);
|
||||||
|
servicePaths.add(servicePath);
|
||||||
|
} else {
|
||||||
|
String serviceCode = generateServiceCode(domain, endpointName, config, config.getParams());
|
||||||
|
Path serviceFilePath = writeFile(servicePath, serviceCode, true);
|
||||||
|
servicePaths.add(serviceFilePath.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller: 모든 엔드포인트를 합치므로 항상 재생성
|
||||||
|
String controllerCode = generateControllerCode(domain, configs);
|
||||||
|
String domainCapitalized = capitalize(domain);
|
||||||
|
Path controllerFilePath = writeFile(
|
||||||
|
basePath + "/controller/" + domainCapitalized + "Controller.java", controllerCode, true);
|
||||||
|
|
||||||
|
log.info("코드 생성 완료 - domain: {}, endpoints: {}, controller: {}",
|
||||||
|
domain, configs.stream().map(BypassApiConfig::getEndpointName).toList(), controllerFilePath);
|
||||||
|
|
||||||
|
return CodeGenerationResult.builder()
|
||||||
|
.controllerPath(controllerFilePath.toString())
|
||||||
|
.servicePaths(servicePaths)
|
||||||
|
.message("코드 생성 완료. 서버를 재시작하면 새 API가 활성화됩니다.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service 코드 생성 (RAW 모드).
|
||||||
|
* BaseBypassService<JsonNode>를 상속하여 fetchRawGet/fetchRawPost로 JsonNode를 반환합니다.
|
||||||
|
*/
|
||||||
|
private String generateServiceCode(String domain, String endpointName,
|
||||||
|
BypassApiConfig config, List<BypassApiParam> params) {
|
||||||
|
String packageName = BASE_PACKAGE + "." + domain + ".service";
|
||||||
|
String serviceClass = endpointName + "Service";
|
||||||
|
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
|
||||||
|
|
||||||
|
String methodName = "get" + endpointName + "Data";
|
||||||
|
String fetchMethod = buildFetchMethodCall(config, params, isPost);
|
||||||
|
String methodParams = buildMethodParams(params);
|
||||||
|
|
||||||
|
return """
|
||||||
|
package {{PACKAGE}};
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.snp.batch.common.web.service.BaseBypassService;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {{DISPLAY_NAME}} bypass 서비스
|
||||||
|
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class {{SERVICE_CLASS}} extends BaseBypassService<JsonNode> {
|
||||||
|
|
||||||
|
public {{SERVICE_CLASS}}(
|
||||||
|
@Qualifier("{{WEBCLIENT_BEAN}}") WebClient webClient) {
|
||||||
|
super(webClient, "{{EXTERNAL_PATH}}", "{{DISPLAY_NAME}}",
|
||||||
|
new ParameterizedTypeReference<>() {},
|
||||||
|
new ParameterizedTypeReference<>() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {{DISPLAY_NAME}} 데이터를 조회합니다.
|
||||||
|
*/
|
||||||
|
public JsonNode {{METHOD_NAME}}({{METHOD_PARAMS}}) {
|
||||||
|
{{FETCH_METHOD}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
.replace("{{PACKAGE}}", packageName)
|
||||||
|
.replace("{{DISPLAY_NAME}}", config.getDisplayName())
|
||||||
|
.replace("{{SERVICE_CLASS}}", serviceClass)
|
||||||
|
.replace("{{WEBCLIENT_BEAN}}", config.getWebclientBean())
|
||||||
|
.replace("{{EXTERNAL_PATH}}", config.getExternalPath())
|
||||||
|
.replace("{{METHOD_NAME}}", methodName)
|
||||||
|
.replace("{{METHOD_PARAMS}}", methodParams)
|
||||||
|
.replace("{{FETCH_METHOD}}", fetchMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller 코드 생성 (RAW 모드).
|
||||||
|
* 모든 엔드포인트가 ResponseEntity<ApiResponse<JsonNode>>를 반환합니다.
|
||||||
|
*/
|
||||||
|
private String generateControllerCode(String domain, List<BypassApiConfig> configs) {
|
||||||
|
String packageName = BASE_PACKAGE + "." + domain + ".controller";
|
||||||
|
String servicePackage = BASE_PACKAGE + "." + domain + ".service";
|
||||||
|
String domainCap = capitalize(domain);
|
||||||
|
String requestMappingPath = "/api/" + domain;
|
||||||
|
|
||||||
|
// imports (중복 제거)
|
||||||
|
Set<String> importSet = new LinkedHashSet<>();
|
||||||
|
importSet.add("import com.fasterxml.jackson.databind.JsonNode;");
|
||||||
|
importSet.add("import com.snp.batch.common.web.ApiResponse;");
|
||||||
|
importSet.add("import com.snp.batch.common.web.controller.BaseBypassController;");
|
||||||
|
importSet.add("import io.swagger.v3.oas.annotations.Operation;");
|
||||||
|
importSet.add("import io.swagger.v3.oas.annotations.Parameter;");
|
||||||
|
importSet.add("import io.swagger.v3.oas.annotations.tags.Tag;");
|
||||||
|
importSet.add("import lombok.RequiredArgsConstructor;");
|
||||||
|
importSet.add("import org.springframework.http.ResponseEntity;");
|
||||||
|
importSet.add("import org.springframework.web.bind.annotation.RequestMapping;");
|
||||||
|
importSet.add("import org.springframework.web.bind.annotation.RestController;");
|
||||||
|
|
||||||
|
boolean anyPost = configs.stream().anyMatch(c -> "POST".equalsIgnoreCase(c.getHttpMethod()));
|
||||||
|
boolean anyGet = configs.stream().anyMatch(c -> !"POST".equalsIgnoreCase(c.getHttpMethod()));
|
||||||
|
boolean anyPath = configs.stream()
|
||||||
|
.anyMatch(c -> c.getParams().stream().anyMatch(p -> "PATH".equalsIgnoreCase(p.getParamIn())));
|
||||||
|
boolean anyQuery = configs.stream()
|
||||||
|
.anyMatch(c -> c.getParams().stream().anyMatch(p -> "QUERY".equalsIgnoreCase(p.getParamIn())));
|
||||||
|
boolean anyBody = configs.stream()
|
||||||
|
.anyMatch(c -> c.getParams().stream().anyMatch(p -> "BODY".equalsIgnoreCase(p.getParamIn())));
|
||||||
|
|
||||||
|
if (anyPost) importSet.add("import org.springframework.web.bind.annotation.PostMapping;");
|
||||||
|
if (anyGet) importSet.add("import org.springframework.web.bind.annotation.GetMapping;");
|
||||||
|
if (anyPath) importSet.add("import org.springframework.web.bind.annotation.PathVariable;");
|
||||||
|
if (anyQuery) importSet.add("import org.springframework.web.bind.annotation.RequestParam;");
|
||||||
|
if (anyBody) importSet.add("import org.springframework.web.bind.annotation.RequestBody;");
|
||||||
|
|
||||||
|
for (BypassApiConfig config : configs) {
|
||||||
|
importSet.add("import " + servicePackage + "." + config.getEndpointName() + "Service;");
|
||||||
|
}
|
||||||
|
|
||||||
|
String importsStr = importSet.stream().collect(Collectors.joining("\n"));
|
||||||
|
|
||||||
|
// 필드 선언부
|
||||||
|
StringBuilder fields = new StringBuilder();
|
||||||
|
for (BypassApiConfig config : configs) {
|
||||||
|
String serviceClass = config.getEndpointName() + "Service";
|
||||||
|
String serviceField = Character.toLowerCase(serviceClass.charAt(0)) + serviceClass.substring(1);
|
||||||
|
fields.append(" private final ").append(serviceClass).append(" ").append(serviceField).append(";\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
String tagPrefix = getTagPrefix(configs.get(0).getWebclientBean());
|
||||||
|
String tagDescription = tagPrefix + " " + domainCap + " bypass API";
|
||||||
|
|
||||||
|
// 엔드포인트 메서드 목록
|
||||||
|
StringBuilder methods = new StringBuilder();
|
||||||
|
for (BypassApiConfig config : configs) {
|
||||||
|
String endpointName = config.getEndpointName();
|
||||||
|
String serviceClass = endpointName + "Service";
|
||||||
|
String serviceField = Character.toLowerCase(serviceClass.charAt(0)) + serviceClass.substring(1);
|
||||||
|
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
|
||||||
|
|
||||||
|
String mappingAnnotation = isPost ? "@PostMapping" : "@GetMapping";
|
||||||
|
String mappingPath = buildMappingPath(config.getParams(), config.getExternalPath());
|
||||||
|
String paramAnnotations = buildControllerParamAnnotations(config.getParams());
|
||||||
|
String serviceCallArgs = buildServiceCallArgs(config.getParams());
|
||||||
|
String methodName = "get" + endpointName + "Data";
|
||||||
|
|
||||||
|
methods.append("\n");
|
||||||
|
methods.append(" @Operation(\n");
|
||||||
|
methods.append(" summary = \"").append(config.getDisplayName()).append("\",\n");
|
||||||
|
String opDescription = (config.getDescription() != null && !config.getDescription().isEmpty())
|
||||||
|
? config.getDescription()
|
||||||
|
: config.getDisplayName() + " 데이터를 요청하고 응답을 그대로 반환합니다.";
|
||||||
|
methods.append(" description = \"").append(opDescription).append("\"\n");
|
||||||
|
methods.append(" )\n");
|
||||||
|
methods.append(" ").append(mappingAnnotation).append(mappingPath).append("\n");
|
||||||
|
methods.append(" public ResponseEntity<ApiResponse<JsonNode>> ").append(methodName).append("(");
|
||||||
|
if (!paramAnnotations.isEmpty()) {
|
||||||
|
methods.append(paramAnnotations);
|
||||||
|
}
|
||||||
|
methods.append(") {\n");
|
||||||
|
methods.append(" return execute(() -> ").append(serviceField)
|
||||||
|
.append(".").append(methodName).append("(").append(serviceCallArgs).append("));\n");
|
||||||
|
methods.append(" }\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "package " + packageName + ";\n\n"
|
||||||
|
+ importsStr + "\n\n"
|
||||||
|
+ "/**\n"
|
||||||
|
+ " * " + domainCap + " bypass API\n"
|
||||||
|
+ " * S&P Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환\n"
|
||||||
|
+ " */\n"
|
||||||
|
+ "@RestController\n"
|
||||||
|
+ "@RequestMapping(\"" + requestMappingPath + "\")\n"
|
||||||
|
+ "@RequiredArgsConstructor\n"
|
||||||
|
+ "@Tag(name = \"" + domainCap + "\", description = \"" + tagDescription + "\")\n"
|
||||||
|
+ "public class " + domainCap + "Controller extends BaseBypassController {\n\n"
|
||||||
|
+ fields
|
||||||
|
+ methods
|
||||||
|
+ "}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAW fetch 메서드 호출 코드 생성
|
||||||
|
*/
|
||||||
|
private String buildFetchMethodCall(BypassApiConfig config, List<BypassApiParam> params, boolean isPost) {
|
||||||
|
List<BypassApiParam> queryParams = params.stream()
|
||||||
|
.filter(p -> "QUERY".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
StringBuilder uriBuilder = new StringBuilder();
|
||||||
|
uriBuilder.append("uri -> uri.path(getApiPath())");
|
||||||
|
for (BypassApiParam p : queryParams) {
|
||||||
|
uriBuilder.append("\n .queryParam(\"")
|
||||||
|
.append(p.getParamName()).append("\", ").append(p.getParamName()).append(")");
|
||||||
|
}
|
||||||
|
uriBuilder.append("\n .build()");
|
||||||
|
|
||||||
|
if (isPost) {
|
||||||
|
BypassApiParam bodyParam = params.stream()
|
||||||
|
.filter(p -> "BODY".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
String bodyArg = bodyParam != null ? bodyParam.getParamName() : "null";
|
||||||
|
return "return fetchRawPost(" + bodyArg + ", " + uriBuilder + ");";
|
||||||
|
} else {
|
||||||
|
return "return fetchRawGet(" + uriBuilder + ");";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildMethodParams(List<BypassApiParam> params) {
|
||||||
|
return params.stream()
|
||||||
|
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
|
||||||
|
.map(p -> toJavaType(p.getParamType()) + " " + p.getParamName())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildServiceCallArgs(List<BypassApiParam> params) {
|
||||||
|
return params.stream()
|
||||||
|
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
|
||||||
|
.map(BypassApiParam::getParamName)
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildControllerParamAnnotations(List<BypassApiParam> params) {
|
||||||
|
if (params.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return params.stream()
|
||||||
|
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
|
||||||
|
.map(p -> {
|
||||||
|
String description = p.getDescription() != null ? p.getDescription() : p.getParamName();
|
||||||
|
String javaType = toJavaType(p.getParamType());
|
||||||
|
String paramName = p.getParamName();
|
||||||
|
String example = (p.getExample() != null && !p.getExample().isEmpty())
|
||||||
|
? p.getExample()
|
||||||
|
: getDefaultExample(p.getParamType());
|
||||||
|
return switch (p.getParamIn().toUpperCase()) {
|
||||||
|
case "PATH" -> "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n"
|
||||||
|
+ " @PathVariable " + javaType + " " + paramName;
|
||||||
|
case "BODY" -> "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n"
|
||||||
|
+ " @RequestBody " + javaType + " " + paramName;
|
||||||
|
default -> {
|
||||||
|
String required = Boolean.TRUE.equals(p.getRequired()) ? "true" : "false";
|
||||||
|
yield "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n"
|
||||||
|
+ " @RequestParam(required = " + required + ") " + javaType + " " + paramName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.collect(Collectors.joining(",\n "));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildMappingPath(List<BypassApiParam> params, String externalPath) {
|
||||||
|
String endpointSegment = "";
|
||||||
|
if (externalPath != null && !externalPath.isEmpty()) {
|
||||||
|
String[] segments = externalPath.split("/");
|
||||||
|
if (segments.length > 0) {
|
||||||
|
endpointSegment = "/" + segments[segments.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BypassApiParam> pathParams = params.stream()
|
||||||
|
.filter(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
|
||||||
|
.toList();
|
||||||
|
String pathSuffix = pathParams.stream()
|
||||||
|
.map(p -> "{" + p.getParamName() + "}")
|
||||||
|
.collect(Collectors.joining("/", pathParams.isEmpty() ? "" : "/", ""));
|
||||||
|
|
||||||
|
String fullPath = endpointSegment + pathSuffix;
|
||||||
|
if (fullPath.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "(\"" + fullPath + "\")";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path writeFile(String path, String content, boolean force) {
|
||||||
|
Path filePath = Path.of(path);
|
||||||
|
if (Files.exists(filePath) && !force) {
|
||||||
|
throw new IllegalStateException("파일이 이미 존재합니다: " + path + " (덮어쓰려면 force 옵션을 사용하세요)");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.createDirectories(filePath.getParent());
|
||||||
|
Files.writeString(filePath, content, StandardCharsets.UTF_8);
|
||||||
|
log.info("파일 생성: {}", filePath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("파일 쓰기 실패: " + path, e);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getTagPrefix(String webclientBean) {
|
||||||
|
if (webclientBean == null) {
|
||||||
|
return "[Ship API]";
|
||||||
|
}
|
||||||
|
return switch (webclientBean) {
|
||||||
|
case "maritimeAisApiWebClient" -> "[AIS API]";
|
||||||
|
case "maritimeServiceApiWebClient" -> "[Service API]";
|
||||||
|
default -> "[Ship API]";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getDefaultExample(String paramType) {
|
||||||
|
if (paramType == null) {
|
||||||
|
return "9876543";
|
||||||
|
}
|
||||||
|
return switch (paramType.toUpperCase()) {
|
||||||
|
case "INTEGER" -> "0";
|
||||||
|
case "LONG" -> "0";
|
||||||
|
case "BOOLEAN" -> "true";
|
||||||
|
default -> "9876543";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String capitalize(String s) {
|
||||||
|
if (s == null || s.isEmpty()) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJavaType(String paramType) {
|
||||||
|
if (paramType == null) {
|
||||||
|
return "String";
|
||||||
|
}
|
||||||
|
return switch (paramType.toUpperCase()) {
|
||||||
|
case "INTEGER" -> "Integer";
|
||||||
|
case "LONG" -> "Long";
|
||||||
|
case "BOOLEAN" -> "Boolean";
|
||||||
|
default -> "String";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/main/java/com/snp/batch/service/BypassConfigService.java
Normal file
189
src/main/java/com/snp/batch/service/BypassConfigService.java
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
package com.snp.batch.service;
|
||||||
|
|
||||||
|
import com.snp.batch.global.dto.BypassConfigRequest;
|
||||||
|
import com.snp.batch.global.dto.BypassConfigResponse;
|
||||||
|
import com.snp.batch.global.dto.BypassParamDto;
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import com.snp.batch.global.model.BypassApiParam;
|
||||||
|
import com.snp.batch.global.repository.BypassApiConfigRepository;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 CRUD 비즈니스 로직
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BypassConfigService {
|
||||||
|
|
||||||
|
private final BypassApiConfigRepository configRepository;
|
||||||
|
private final EntityManager entityManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 목록 조회
|
||||||
|
*/
|
||||||
|
public List<BypassConfigResponse> getConfigs() {
|
||||||
|
return configRepository.findAll().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 상세 조회
|
||||||
|
*/
|
||||||
|
public BypassConfigResponse getConfig(Long id) {
|
||||||
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||||
|
return toResponse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 등록
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public BypassConfigResponse createConfig(BypassConfigRequest request) {
|
||||||
|
String endpointName = extractEndpointName(request.getExternalPath());
|
||||||
|
if (configRepository.existsByDomainNameAndEndpointName(request.getDomainName(), endpointName)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"이미 존재하는 도메인+엔드포인트 조합입니다: " + request.getDomainName() + "/" + endpointName);
|
||||||
|
}
|
||||||
|
BypassApiConfig config = toEntity(request);
|
||||||
|
|
||||||
|
if (request.getParams() != null) {
|
||||||
|
request.getParams().forEach(p -> {
|
||||||
|
BypassApiParam param = toParamEntity(p);
|
||||||
|
param.setConfig(config);
|
||||||
|
config.getParams().add(param);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return toResponse(configRepository.save(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 수정
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public BypassConfigResponse updateConfig(Long id, BypassConfigRequest request) {
|
||||||
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||||
|
|
||||||
|
config.setDomainName(request.getDomainName());
|
||||||
|
config.setDisplayName(request.getDisplayName());
|
||||||
|
config.setWebclientBean(request.getWebclientBean());
|
||||||
|
config.setExternalPath(request.getExternalPath());
|
||||||
|
config.setHttpMethod(request.getHttpMethod());
|
||||||
|
config.setDescription(request.getDescription());
|
||||||
|
|
||||||
|
// params 교체: clear → flush(DELETE 실행) → 새로 추가
|
||||||
|
config.getParams().clear();
|
||||||
|
entityManager.flush();
|
||||||
|
|
||||||
|
if (request.getParams() != null) {
|
||||||
|
request.getParams().forEach(p -> {
|
||||||
|
BypassApiParam param = toParamEntity(p);
|
||||||
|
param.setConfig(config);
|
||||||
|
config.getParams().add(param);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResponse(configRepository.save(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 삭제
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteConfig(Long id) {
|
||||||
|
if (!configRepository.existsById(id)) {
|
||||||
|
throw new IllegalArgumentException("설정을 찾을 수 없습니다: " + id);
|
||||||
|
}
|
||||||
|
configRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 생성 완료 마킹
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void markAsGenerated(Long id) {
|
||||||
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||||
|
config.setGenerated(true);
|
||||||
|
config.setGeneratedAt(LocalDateTime.now());
|
||||||
|
configRepository.save(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- private 헬퍼 메서드 ----
|
||||||
|
|
||||||
|
private BypassConfigResponse toResponse(BypassApiConfig config) {
|
||||||
|
return BypassConfigResponse.builder()
|
||||||
|
.id(config.getId())
|
||||||
|
.domainName(config.getDomainName())
|
||||||
|
.endpointName(config.getEndpointName())
|
||||||
|
.displayName(config.getDisplayName())
|
||||||
|
.webclientBean(config.getWebclientBean())
|
||||||
|
.externalPath(config.getExternalPath())
|
||||||
|
.httpMethod(config.getHttpMethod())
|
||||||
|
.description(config.getDescription())
|
||||||
|
.generated(config.getGenerated())
|
||||||
|
.generatedAt(config.getGeneratedAt())
|
||||||
|
.createdAt(config.getCreatedAt())
|
||||||
|
.updatedAt(config.getUpdatedAt())
|
||||||
|
.params(config.getParams().stream().map(this::toParamDto).toList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassApiConfig toEntity(BypassConfigRequest request) {
|
||||||
|
return BypassApiConfig.builder()
|
||||||
|
.domainName(request.getDomainName())
|
||||||
|
.endpointName(extractEndpointName(request.getExternalPath()))
|
||||||
|
.displayName(request.getDisplayName())
|
||||||
|
.webclientBean(request.getWebclientBean())
|
||||||
|
.externalPath(request.getExternalPath())
|
||||||
|
.httpMethod(request.getHttpMethod() != null ? request.getHttpMethod() : "GET")
|
||||||
|
.description(request.getDescription())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* externalPath의 마지막 세그먼트를 endpointName으로 추출
|
||||||
|
*/
|
||||||
|
private String extractEndpointName(String externalPath) {
|
||||||
|
if (externalPath == null || externalPath.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String[] segments = externalPath.split("/");
|
||||||
|
return segments[segments.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassApiParam toParamEntity(BypassParamDto dto) {
|
||||||
|
return BypassApiParam.builder()
|
||||||
|
.paramName(dto.getParamName())
|
||||||
|
.paramType(dto.getParamType() != null ? dto.getParamType() : "STRING")
|
||||||
|
.paramIn(dto.getParamIn() != null ? dto.getParamIn() : "QUERY")
|
||||||
|
.required(dto.getRequired() != null ? dto.getRequired() : true)
|
||||||
|
.description(dto.getDescription())
|
||||||
|
.sortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0)
|
||||||
|
.example(dto.getExample())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassParamDto toParamDto(BypassApiParam param) {
|
||||||
|
return BypassParamDto.builder()
|
||||||
|
.id(param.getId())
|
||||||
|
.paramName(param.getParamName())
|
||||||
|
.paramType(param.getParamType())
|
||||||
|
.paramIn(param.getParamIn())
|
||||||
|
.required(param.getRequired())
|
||||||
|
.description(param.getDescription())
|
||||||
|
.sortOrder(param.getSortOrder())
|
||||||
|
.example(param.getExample())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user