feat: BYPASS API 등록 프로세스 설계 및 개발 (#63)
- 공통 베이스 클래스 추가 (BaseBypassService, BaseBypassController) - 기존 Risk 모듈 베이스 클래스 상속으로 리팩토링 - BYPASS API 설정 CRUD API 구현 (/api/bypass-config) - Java 코드 생성기 구현 (Controller, Service, DTO 자동 생성) - JSON 샘플 파싱 기능 구현 - 프론트엔드 BYPASS API 관리 페이지 추가 (멀티스텝 등록 모달) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
7c7617bd19
커밋
951b6c759d
@ -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>
|
||||||
|
|||||||
123
frontend/src/api/bypassApi.ts
Normal file
123
frontend/src/api/bypassApi.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// 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;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassFieldDto {
|
||||||
|
id?: number;
|
||||||
|
fieldName: string;
|
||||||
|
jsonProperty: string | null;
|
||||||
|
fieldType: string; // String, Integer, Long, Double, Boolean, LocalDateTime, List<Object>, Object
|
||||||
|
description: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassConfigRequest {
|
||||||
|
domainName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
responseType: string;
|
||||||
|
description: string;
|
||||||
|
params: BypassParamDto[];
|
||||||
|
fields: BypassFieldDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassConfigResponse {
|
||||||
|
id: number;
|
||||||
|
domainName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
responseType: string;
|
||||||
|
description: string;
|
||||||
|
generated: boolean;
|
||||||
|
generatedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
params: BypassParamDto[];
|
||||||
|
fields: BypassFieldDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenerationResult {
|
||||||
|
controllerPath: string;
|
||||||
|
servicePath: string;
|
||||||
|
dtoPath: 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}`),
|
||||||
|
parseJson: (jsonSample: string) =>
|
||||||
|
postJson<ApiResponse<BypassFieldDto[]>>(`${BASE}/parse-json`, jsonSample),
|
||||||
|
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() {
|
||||||
|
|||||||
239
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
239
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type {
|
||||||
|
BypassConfigRequest,
|
||||||
|
BypassConfigResponse,
|
||||||
|
BypassParamDto,
|
||||||
|
BypassFieldDto,
|
||||||
|
WebClientBeanInfo,
|
||||||
|
} from '../../api/bypassApi';
|
||||||
|
import BypassStepBasic from './BypassStepBasic';
|
||||||
|
import BypassStepParams from './BypassStepParams';
|
||||||
|
import BypassStepFields from './BypassStepFields';
|
||||||
|
|
||||||
|
interface BypassConfigModalProps {
|
||||||
|
open: boolean;
|
||||||
|
editConfig: BypassConfigResponse | null;
|
||||||
|
webclientBeans: WebClientBeanInfo[];
|
||||||
|
onSave: (data: BypassConfigRequest) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepNumber = 1 | 2 | 3;
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<StepNumber, string> = {
|
||||||
|
1: '기본 정보',
|
||||||
|
2: '파라미터',
|
||||||
|
3: 'DTO 필드',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FORM: Omit<BypassConfigRequest, 'params' | 'fields'> = {
|
||||||
|
domainName: '',
|
||||||
|
displayName: '',
|
||||||
|
webclientBean: '',
|
||||||
|
externalPath: '',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
responseType: 'LIST',
|
||||||
|
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 [responseType, setResponseType] = useState('LIST');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [params, setParams] = useState<BypassParamDto[]>([]);
|
||||||
|
const [fields, setFields] = useState<BypassFieldDto[]>([]);
|
||||||
|
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);
|
||||||
|
setResponseType(editConfig.responseType);
|
||||||
|
setDescription(editConfig.description);
|
||||||
|
setParams(editConfig.params);
|
||||||
|
setFields(editConfig.fields);
|
||||||
|
} else {
|
||||||
|
setDomainName(DEFAULT_FORM.domainName);
|
||||||
|
setDisplayName(DEFAULT_FORM.displayName);
|
||||||
|
setWebclientBean(DEFAULT_FORM.webclientBean);
|
||||||
|
setExternalPath(DEFAULT_FORM.externalPath);
|
||||||
|
setHttpMethod(DEFAULT_FORM.httpMethod);
|
||||||
|
setResponseType(DEFAULT_FORM.responseType);
|
||||||
|
setDescription(DEFAULT_FORM.description);
|
||||||
|
setParams([]);
|
||||||
|
setFields([]);
|
||||||
|
}
|
||||||
|
}, [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 'responseType': setResponseType(value); break;
|
||||||
|
case 'description': setDescription(value); break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
domainName,
|
||||||
|
displayName,
|
||||||
|
webclientBean,
|
||||||
|
externalPath,
|
||||||
|
httpMethod,
|
||||||
|
responseType,
|
||||||
|
description,
|
||||||
|
params,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps: StepNumber[] = [1, 2, 3];
|
||||||
|
|
||||||
|
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}
|
||||||
|
responseType={responseType}
|
||||||
|
description={description}
|
||||||
|
webclientBeans={webclientBeans}
|
||||||
|
isEdit={editConfig !== null}
|
||||||
|
onChange={handleBasicChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<BypassStepParams params={params} onChange={setParams} />
|
||||||
|
)}
|
||||||
|
{step === 3 && (
|
||||||
|
<BypassStepFields fields={fields} onChange={setFields} />
|
||||||
|
)}
|
||||||
|
</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 < 3 ? (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
168
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import type { WebClientBeanInfo } from '../../api/bypassApi';
|
||||||
|
|
||||||
|
interface BypassStepBasicProps {
|
||||||
|
domainName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
responseType: string;
|
||||||
|
description: string;
|
||||||
|
webclientBeans: WebClientBeanInfo[];
|
||||||
|
isEdit: boolean;
|
||||||
|
onChange: (field: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassStepBasic({
|
||||||
|
domainName,
|
||||||
|
displayName,
|
||||||
|
webclientBean,
|
||||||
|
externalPath,
|
||||||
|
httpMethod,
|
||||||
|
responseType,
|
||||||
|
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.name}{bean.description ? ` — ${bean.description}` : ''}
|
||||||
|
</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>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
응답 타입 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['LIST', 'OBJECT'].map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('responseType', type)}
|
||||||
|
className={[
|
||||||
|
'flex-1 py-2 text-sm font-medium rounded-lg border transition-colors',
|
||||||
|
responseType === type
|
||||||
|
? 'bg-wing-accent text-white border-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:bg-wing-hover',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
frontend/src/components/bypass/BypassStepFields.tsx
Normal file
233
frontend/src/components/bypass/BypassStepFields.tsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { BypassFieldDto } from '../../api/bypassApi';
|
||||||
|
import { bypassApi } from '../../api/bypassApi';
|
||||||
|
|
||||||
|
interface BypassStepFieldsProps {
|
||||||
|
fields: BypassFieldDto[];
|
||||||
|
onChange: (fields: BypassFieldDto[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveTab = 'manual' | 'json';
|
||||||
|
|
||||||
|
const FIELD_TYPES = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'LocalDateTime', 'List<Object>', 'Object'];
|
||||||
|
|
||||||
|
function createEmptyField(sortOrder: number): BypassFieldDto {
|
||||||
|
return {
|
||||||
|
fieldName: '',
|
||||||
|
jsonProperty: null,
|
||||||
|
fieldType: 'String',
|
||||||
|
description: '',
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassStepFields({ fields, onChange }: BypassStepFieldsProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>('manual');
|
||||||
|
const [jsonSample, setJsonSample] = useState('');
|
||||||
|
const [parsing, setParsing] = useState(false);
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
onChange([...fields, createEmptyField(fields.length)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const updated = fields
|
||||||
|
.filter((_, i) => i !== index)
|
||||||
|
.map((f, i) => ({ ...f, sortOrder: i }));
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, field: keyof BypassFieldDto, value: string | null) => {
|
||||||
|
const updated = fields.map((f, i) =>
|
||||||
|
i === index ? { ...f, [field]: value } : f,
|
||||||
|
);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleParseJson = async () => {
|
||||||
|
if (!jsonSample.trim()) {
|
||||||
|
setParseError('JSON 샘플을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setParseError(null);
|
||||||
|
setParsing(true);
|
||||||
|
try {
|
||||||
|
const result = await bypassApi.parseJson(jsonSample);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
onChange(result.data);
|
||||||
|
setActiveTab('manual');
|
||||||
|
} else {
|
||||||
|
setParseError(result.message ?? 'JSON 파싱에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(err instanceof Error ? err.message : 'JSON 파싱 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setParsing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
응답 DTO에 포함할 필드를 정의하세요. JSON 샘플을 붙여넣어 자동 파싱할 수도 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-1 border-b border-wing-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('manual')}
|
||||||
|
className={[
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
activeTab === 'manual'
|
||||||
|
? 'border-wing-accent text-wing-accent'
|
||||||
|
: 'border-transparent text-wing-muted hover:text-wing-text',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
직접 입력
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('json')}
|
||||||
|
className={[
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
activeTab === 'json'
|
||||||
|
? 'border-wing-accent text-wing-accent'
|
||||||
|
: 'border-transparent text-wing-muted hover:text-wing-text',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
JSON 샘플
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 직접 입력 탭 */}
|
||||||
|
{activeTab === 'manual' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-wing-muted border border-dashed border-wing-border rounded-lg bg-wing-card">
|
||||||
|
DTO 필드가 없습니다. 추가 버튼을 클릭하거나 JSON 샘플로 자동 파싱하세요.
|
||||||
|
</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-[130px]">필드명</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[130px]">JSON Property</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[150px]">타입</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3">설명</th>
|
||||||
|
<th className="pb-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<tr key={index} className="group">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={field.fieldName}
|
||||||
|
onChange={(e) => handleChange(index, 'fieldName', e.target.value)}
|
||||||
|
placeholder="fieldName"
|
||||||
|
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={field.jsonProperty ?? ''}
|
||||||
|
onChange={(e) => handleChange(index, 'jsonProperty', e.target.value || null)}
|
||||||
|
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">
|
||||||
|
<select
|
||||||
|
value={field.fieldType}
|
||||||
|
onChange={(e) => handleChange(index, 'fieldType', 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"
|
||||||
|
>
|
||||||
|
{FIELD_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={field.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">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* JSON 샘플 탭 */}
|
||||||
|
{activeTab === 'json' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
응답 JSON 샘플을 붙여넣으면 필드 구조를 자동으로 파싱합니다. 파싱 후 직접 입력 탭에서 수정할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={jsonSample}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJsonSample(e.target.value);
|
||||||
|
setParseError(null);
|
||||||
|
}}
|
||||||
|
rows={10}
|
||||||
|
placeholder={'{\n "riskScore": 85,\n "imoNumber": "1234567",\n "vesselName": "EXAMPLE SHIP"\n}'}
|
||||||
|
className="w-full px-3 py-2 text-sm font-mono 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"
|
||||||
|
/>
|
||||||
|
{parseError && (
|
||||||
|
<p className="text-sm text-red-500">{parseError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleParseJson}
|
||||||
|
disabled={parsing}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{parsing && (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{parsing ? '파싱 중...' : '파싱'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
146
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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: '',
|
||||||
|
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 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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
frontend/src/pages/BypassConfig.tsx
Normal file
279
frontend/src/pages/BypassConfig.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import { useState, useEffect, useCallback } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 카드 그리드 */}
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{configs.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<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>
|
||||||
|
{[
|
||||||
|
{ label: 'Controller', path: generationResult.controllerPath },
|
||||||
|
{ label: 'Service', path: generationResult.servicePath },
|
||||||
|
{ label: 'DTO', path: generationResult.dtoPath },
|
||||||
|
].map(({ label, path }) => (
|
||||||
|
<div key={label} className="flex gap-2 text-xs">
|
||||||
|
<span className="w-20 font-medium text-wing-accent shrink-0">{label}</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,101 @@
|
|||||||
|
package com.snp.batch.common.web.service;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getApiPath() {
|
||||||
|
return apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
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.BypassFieldDto;
|
||||||
|
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 com.snp.batch.service.JsonSchemaParser;
|
||||||
|
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 JsonSchemaParser jsonSchemaParser;
|
||||||
|
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, DTO Java 소스 코드를 생성합니다."
|
||||||
|
)
|
||||||
|
@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));
|
||||||
|
|
||||||
|
CodeGenerationResult result = bypassCodeGenerator.generate(
|
||||||
|
config, config.getParams(), config.getFields(), force);
|
||||||
|
|
||||||
|
bypassConfigService.markAsGenerated(id);
|
||||||
|
|
||||||
|
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 = "JSON 샘플 파싱",
|
||||||
|
description = "JSON 샘플에서 DTO 필드 목록을 추출합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/parse-json")
|
||||||
|
public ResponseEntity<ApiResponse<List<BypassFieldDto>>> parseJson(@RequestBody String jsonSample) {
|
||||||
|
try {
|
||||||
|
List<BypassFieldDto> fields = jsonSchemaParser.parse(jsonSample);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(fields));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.error("JSON 파싱 실패: " + 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
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 responseType;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
|
||||||
|
/** 응답 필드 목록 */
|
||||||
|
private List<BypassFieldDto> fields;
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/** WebClient 빈 이름 */
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/** 외부 API 경로 */
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/** HTTP 메서드 */
|
||||||
|
private String httpMethod;
|
||||||
|
|
||||||
|
/** 응답 타입 */
|
||||||
|
private String responseType;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 코드 생성 완료 여부 */
|
||||||
|
private Boolean generated;
|
||||||
|
|
||||||
|
/** 코드 생성 일시 */
|
||||||
|
private LocalDateTime generatedAt;
|
||||||
|
|
||||||
|
/** 생성 일시 */
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** 수정 일시 */
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
|
||||||
|
/** 응답 필드 목록 */
|
||||||
|
private List<BypassFieldDto> fields;
|
||||||
|
}
|
||||||
33
src/main/java/com/snp/batch/global/dto/BypassFieldDto.java
Normal file
33
src/main/java/com/snp/batch/global/dto/BypassFieldDto.java
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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 BypassFieldDto {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 필드 이름 (Java 클래스 필드명) */
|
||||||
|
private String fieldName;
|
||||||
|
|
||||||
|
/** JSON 프로퍼티명 (null이면 fieldName 사용) */
|
||||||
|
private String jsonProperty;
|
||||||
|
|
||||||
|
/** 필드 타입 (String, Integer, Long, Double, Boolean, LocalDateTime) */
|
||||||
|
private String fieldType;
|
||||||
|
|
||||||
|
/** 필드 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 정렬 순서 */
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
36
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
36
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 자동 생성 결과 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CodeGenerationResult {
|
||||||
|
|
||||||
|
/** 생성된 Controller 파일 경로 */
|
||||||
|
private String controllerPath;
|
||||||
|
|
||||||
|
/** 생성된 Service 파일 경로 */
|
||||||
|
private String servicePath;
|
||||||
|
|
||||||
|
/** 생성된 DTO 파일 경로 */
|
||||||
|
private String dtoPath;
|
||||||
|
|
||||||
|
/** 결과 메시지 */
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
135
src/main/java/com/snp/batch/global/model/BypassApiConfig.java
Normal file
135
src/main/java/com/snp/batch/global/model/BypassApiConfig.java
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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")
|
||||||
|
@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", unique = true, nullable = false, length = 50)
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시명
|
||||||
|
* 예: "선박 정보 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 응답 타입
|
||||||
|
* 예: "LIST", "SINGLE"
|
||||||
|
*/
|
||||||
|
@Column(name = "response_type", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String responseType = "LIST";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설명
|
||||||
|
*/
|
||||||
|
@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<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답 필드 목록
|
||||||
|
*/
|
||||||
|
@OneToMany(mappedBy = "config", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@Builder.Default
|
||||||
|
private List<BypassApiField> fields = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 저장 전 자동 호출 (INSERT 시)
|
||||||
|
*/
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 업데이트 전 자동 호출 (UPDATE 시)
|
||||||
|
*/
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/main/java/com/snp/batch/global/model/BypassApiField.java
Normal file
69
src/main/java/com/snp/batch/global/model/BypassApiField.java
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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_field",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"config_id", "field_name"})
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassApiField {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연관된 BYPASS API 설정
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "config_id", nullable = false)
|
||||||
|
private BypassApiConfig config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 이름 (Java 클래스 필드명)
|
||||||
|
* 예: "imoNumber", "shipName", "vesselType"
|
||||||
|
*/
|
||||||
|
@Column(name = "field_name", nullable = false, length = 100)
|
||||||
|
private String fieldName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 프로퍼티명 (null이면 fieldName 사용)
|
||||||
|
* 예: "imo_number", "ship_name"
|
||||||
|
*/
|
||||||
|
@Column(name = "json_property", length = 100)
|
||||||
|
private String jsonProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 타입
|
||||||
|
* 예: "String", "Integer", "Long", "Double", "Boolean", "LocalDateTime"
|
||||||
|
*/
|
||||||
|
@Column(name = "field_type", nullable = false, length = 50)
|
||||||
|
@Builder.Default
|
||||||
|
private String fieldType = "String";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 설명
|
||||||
|
*/
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 순서
|
||||||
|
*/
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Integer sortOrder = 0;
|
||||||
|
}
|
||||||
77
src/main/java/com/snp/batch/global/model/BypassApiParam.java
Normal file
77
src/main/java/com/snp/batch/global/model/BypassApiParam.java
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BypassApiConfig Repository
|
||||||
|
* JPA Repository 방식으로 자동 구현
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BypassApiConfigRepository extends JpaRepository<BypassApiConfig, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명으로 BYPASS API 설정 조회
|
||||||
|
*/
|
||||||
|
Optional<BypassApiConfig> findByDomainName(String domainName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByDomainName(String domainName);
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BypassApiField;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BypassApiField Repository
|
||||||
|
* JPA Repository 방식으로 자동 구현
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BypassApiFieldRepository extends JpaRepository<BypassApiField, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 ID로 필드 목록 조회 (정렬 순서 기준 오름차순)
|
||||||
|
*/
|
||||||
|
List<BypassApiField> findByConfigIdOrderBySortOrder(Long configId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 ID로 필드 전체 삭제
|
||||||
|
*/
|
||||||
|
void deleteByConfigId(Long configId);
|
||||||
|
}
|
||||||
@ -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,19 +1,18 @@
|
|||||||
package com.snp.batch.jobs.web.risk.controller;
|
package com.snp.batch.jobs.web.risk.controller;
|
||||||
|
|
||||||
import com.snp.batch.common.web.ApiResponse;
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.common.web.controller.BaseBypassController;
|
||||||
import com.snp.batch.jobs.batch.risk.dto.RiskDto;
|
import com.snp.batch.jobs.batch.risk.dto.RiskDto;
|
||||||
import com.snp.batch.jobs.web.risk.service.RiskBypassService;
|
import com.snp.batch.jobs.web.risk.service.RiskBypassService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -21,12 +20,11 @@ import java.util.List;
|
|||||||
* Risk 상세 조회 bypass API
|
* Risk 상세 조회 bypass API
|
||||||
* S&P Maritime API에서 Risk 데이터를 실시간 조회하여 그대로 반환
|
* S&P Maritime API에서 Risk 데이터를 실시간 조회하여 그대로 반환
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/risk")
|
@RequestMapping("/api/risk")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Tag(name = "Risk", description = "선박 Risk 상세 정보 bypass API")
|
@Tag(name = "Risk", description = "선박 Risk 상세 정보 bypass API")
|
||||||
public class RiskController {
|
public class RiskController extends BaseBypassController {
|
||||||
|
|
||||||
private final RiskBypassService riskBypassService;
|
private final RiskBypassService riskBypassService;
|
||||||
|
|
||||||
@ -37,20 +35,7 @@ public class RiskController {
|
|||||||
@GetMapping("/{imo}")
|
@GetMapping("/{imo}")
|
||||||
public ResponseEntity<ApiResponse<List<RiskDto>>> getRiskDetailByImo(
|
public ResponseEntity<ApiResponse<List<RiskDto>>> getRiskDetailByImo(
|
||||||
@Parameter(description = "IMO 번호", example = "9321483")
|
@Parameter(description = "IMO 번호", example = "9321483")
|
||||||
@PathVariable String imo
|
@PathVariable String imo) {
|
||||||
) {
|
return execute(() -> riskBypassService.getRiskDetailByImo(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,7 +1,7 @@
|
|||||||
package com.snp.batch.jobs.web.risk.service;
|
package com.snp.batch.jobs.web.risk.service;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.service.BaseBypassService;
|
||||||
import com.snp.batch.jobs.batch.risk.dto.RiskDto;
|
import com.snp.batch.jobs.batch.risk.dto.RiskDto;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -13,18 +13,14 @@ import java.util.List;
|
|||||||
* S&P Risk API bypass 서비스
|
* S&P Risk API bypass 서비스
|
||||||
* 외부 Maritime API에서 Risk 상세 데이터를 실시간 조회하여 그대로 반환
|
* 외부 Maritime API에서 Risk 상세 데이터를 실시간 조회하여 그대로 반환
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@Service
|
@Service
|
||||||
public class RiskBypassService {
|
public class RiskBypassService extends BaseBypassService<RiskDto> {
|
||||||
|
|
||||||
private static final String RISK_API_PATH = "/RiskAndCompliance/RisksByImos";
|
|
||||||
|
|
||||||
private final WebClient webClient;
|
|
||||||
|
|
||||||
public RiskBypassService(
|
public RiskBypassService(
|
||||||
@Qualifier("maritimeServiceApiWebClient") WebClient webClient
|
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
|
||||||
) {
|
super(webClient, "/RiskAndCompliance/RisksByImos", "S&P Risk",
|
||||||
this.webClient = webClient;
|
new ParameterizedTypeReference<>() {},
|
||||||
|
new ParameterizedTypeReference<>() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,23 +30,8 @@ public class RiskBypassService {
|
|||||||
* @return Risk 상세 데이터 목록
|
* @return Risk 상세 데이터 목록
|
||||||
*/
|
*/
|
||||||
public List<RiskDto> getRiskDetailByImo(String imo) {
|
public List<RiskDto> getRiskDetailByImo(String imo) {
|
||||||
log.info("S&P Risk API 호출 - IMO: {}", imo);
|
return fetchGetList(uri -> uri.path(getApiPath())
|
||||||
|
.queryParam("imos", imo)
|
||||||
List<RiskDto> response = webClient.get()
|
.build());
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
415
src/main/java/com/snp/batch/service/BypassCodeGenerator.java
Normal file
415
src/main/java/com/snp/batch/service/BypassCodeGenerator.java
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
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.BypassApiField;
|
||||||
|
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.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API Java 소스 코드를 자동 생성하는 서비스.
|
||||||
|
* RiskBypassService / RiskController 패턴을 기반으로 DTO, Service, Controller를 생성합니다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class BypassCodeGenerator {
|
||||||
|
|
||||||
|
private static final String BASE_PACKAGE = "com.snp.batch.jobs.web";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 코드를 생성합니다.
|
||||||
|
*
|
||||||
|
* @param config 설정 정보
|
||||||
|
* @param params 파라미터 목록
|
||||||
|
* @param fields DTO 필드 목록
|
||||||
|
* @param force 기존 파일 덮어쓰기 여부
|
||||||
|
* @return 생성 결과
|
||||||
|
*/
|
||||||
|
public CodeGenerationResult generate(BypassApiConfig config,
|
||||||
|
List<BypassApiParam> params,
|
||||||
|
List<BypassApiField> fields,
|
||||||
|
boolean force) {
|
||||||
|
String projectRoot = System.getProperty("user.dir");
|
||||||
|
String domain = config.getDomainName();
|
||||||
|
String domainCapitalized = capitalize(domain);
|
||||||
|
|
||||||
|
String dtoCode = generateDtoCode(domain, domainCapitalized, fields);
|
||||||
|
String serviceCode = generateServiceCode(domain, domainCapitalized, config, params);
|
||||||
|
String controllerCode = generateControllerCode(domain, domainCapitalized, config, params);
|
||||||
|
|
||||||
|
String basePath = projectRoot + "/src/main/java/com/snp/batch/jobs/web/" + domain;
|
||||||
|
Path dtoPath = writeFile(basePath + "/dto/" + domainCapitalized + "BypassDto.java", dtoCode, force);
|
||||||
|
Path servicePath = writeFile(basePath + "/service/" + domainCapitalized + "BypassService.java", serviceCode, force);
|
||||||
|
Path controllerPath = writeFile(basePath + "/controller/" + domainCapitalized + "Controller.java", controllerCode, force);
|
||||||
|
|
||||||
|
log.info("코드 생성 완료 - domain: {}, dto: {}, service: {}, controller: {}",
|
||||||
|
domain, dtoPath, servicePath, controllerPath);
|
||||||
|
|
||||||
|
return CodeGenerationResult.builder()
|
||||||
|
.dtoPath(dtoPath.toString())
|
||||||
|
.servicePath(servicePath.toString())
|
||||||
|
.controllerPath(controllerPath.toString())
|
||||||
|
.message("코드 생성 완료. 서버를 재시작하면 새 API가 활성화됩니다.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO 코드 생성.
|
||||||
|
* 각 field에 대해 @JsonProperty + private 필드를 생성합니다.
|
||||||
|
*/
|
||||||
|
private String generateDtoCode(String domain, String domainCap, List<BypassApiField> fields) {
|
||||||
|
String packageName = BASE_PACKAGE + "." + domain + ".dto";
|
||||||
|
boolean needsLocalDateTime = fields.stream()
|
||||||
|
.anyMatch(f -> "LocalDateTime".equals(f.getFieldType()));
|
||||||
|
|
||||||
|
StringBuilder imports = new StringBuilder();
|
||||||
|
imports.append("import com.fasterxml.jackson.annotation.JsonProperty;\n");
|
||||||
|
imports.append("import lombok.AllArgsConstructor;\n");
|
||||||
|
imports.append("import lombok.Builder;\n");
|
||||||
|
imports.append("import lombok.Getter;\n");
|
||||||
|
imports.append("import lombok.NoArgsConstructor;\n");
|
||||||
|
imports.append("import lombok.Setter;\n");
|
||||||
|
if (needsLocalDateTime) {
|
||||||
|
imports.append("import java.time.LocalDateTime;\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder fieldLines = new StringBuilder();
|
||||||
|
for (BypassApiField field : fields) {
|
||||||
|
String jsonProp = field.getJsonProperty() != null ? field.getJsonProperty() : field.getFieldName();
|
||||||
|
fieldLines.append(" @JsonProperty(\"%s\")\n".formatted(jsonProp));
|
||||||
|
fieldLines.append(" private %s %s;\n\n".formatted(field.getFieldType(), field.getFieldName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
package %s;
|
||||||
|
|
||||||
|
%s
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class %sBypassDto {
|
||||||
|
|
||||||
|
%s}
|
||||||
|
""".formatted(packageName, imports, domainCap, fieldLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service 코드 생성.
|
||||||
|
* BaseBypassService를 상속하여 GET/POST, LIST/SINGLE 조합에 맞는 fetch 메서드를 생성합니다.
|
||||||
|
*/
|
||||||
|
private String generateServiceCode(String domain, String domainCap,
|
||||||
|
BypassApiConfig config, List<BypassApiParam> params) {
|
||||||
|
String packageName = BASE_PACKAGE + "." + domain + ".service";
|
||||||
|
String dtoPackage = BASE_PACKAGE + "." + domain + ".dto";
|
||||||
|
String dtoClass = domainCap + "BypassDto";
|
||||||
|
boolean isList = "LIST".equalsIgnoreCase(config.getResponseType());
|
||||||
|
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
|
||||||
|
|
||||||
|
String returnType = isList ? "List<%s>".formatted(dtoClass) : dtoClass;
|
||||||
|
String methodName = "get" + domainCap + "Data";
|
||||||
|
String fetchMethod = buildFetchMethodCall(config, params, isList, isPost);
|
||||||
|
String methodParams = buildMethodParams(params);
|
||||||
|
|
||||||
|
String listImport = isList ? "import java.util.List;\n" : "";
|
||||||
|
|
||||||
|
return """
|
||||||
|
package %s;
|
||||||
|
|
||||||
|
import %s.%s;
|
||||||
|
import com.snp.batch.common.web.service.BaseBypassService;
|
||||||
|
%simport org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* %s bypass 서비스
|
||||||
|
* 외부 Maritime API에서 데이터를 실시간 조회하여 그대로 반환
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class %sBypassService extends BaseBypassService<%s> {
|
||||||
|
|
||||||
|
public %sBypassService(
|
||||||
|
@Qualifier("%s") WebClient webClient) {
|
||||||
|
super(webClient, "%s", "%s",
|
||||||
|
new ParameterizedTypeReference<>() {},
|
||||||
|
new ParameterizedTypeReference<>() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* %s 데이터를 조회합니다.
|
||||||
|
*
|
||||||
|
* @return %s
|
||||||
|
*/
|
||||||
|
public %s %s(%s) {
|
||||||
|
%s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(
|
||||||
|
packageName,
|
||||||
|
dtoPackage, dtoClass,
|
||||||
|
listImport,
|
||||||
|
config.getDisplayName(),
|
||||||
|
domainCap, dtoClass,
|
||||||
|
domainCap,
|
||||||
|
config.getWebclientBean(),
|
||||||
|
config.getExternalPath(),
|
||||||
|
config.getDisplayName(),
|
||||||
|
config.getDisplayName(),
|
||||||
|
returnType, methodName, methodParams,
|
||||||
|
fetchMethod
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller 코드 생성.
|
||||||
|
* BaseBypassController를 상속하여 GET/POST 엔드포인트를 생성합니다.
|
||||||
|
*/
|
||||||
|
private String generateControllerCode(String domain, String domainCap,
|
||||||
|
BypassApiConfig config, List<BypassApiParam> params) {
|
||||||
|
String packageName = BASE_PACKAGE + "." + domain + ".controller";
|
||||||
|
String dtoPackage = BASE_PACKAGE + "." + domain + ".dto";
|
||||||
|
String servicePackage = BASE_PACKAGE + "." + domain + ".service";
|
||||||
|
String dtoClass = domainCap + "BypassDto";
|
||||||
|
String serviceClass = domainCap + "BypassService";
|
||||||
|
String serviceField = Character.toLowerCase(serviceClass.charAt(0)) + serviceClass.substring(1);
|
||||||
|
|
||||||
|
boolean isList = "LIST".equalsIgnoreCase(config.getResponseType());
|
||||||
|
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
|
||||||
|
|
||||||
|
String responseGeneric = isList ? "List<%s>".formatted(dtoClass) : dtoClass;
|
||||||
|
String mappingAnnotation = isPost ? "@PostMapping" : "@GetMapping";
|
||||||
|
String listImport = isList ? "import java.util.List;\n" : "";
|
||||||
|
|
||||||
|
String paramAnnotations = buildControllerParamAnnotations(params);
|
||||||
|
String methodParams = buildMethodParams(params);
|
||||||
|
String serviceCallArgs = buildServiceCallArgs(params);
|
||||||
|
String pathVariableImport = params.stream().anyMatch(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
? "import org.springframework.web.bind.annotation.PathVariable;\n" : "";
|
||||||
|
String requestParamImport = params.stream().anyMatch(p -> "QUERY".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
? "import org.springframework.web.bind.annotation.RequestParam;\n" : "";
|
||||||
|
String requestBodyImport = isPost
|
||||||
|
? "import org.springframework.web.bind.annotation.RequestBody;\n" : "";
|
||||||
|
|
||||||
|
String mappingPath = buildMappingPath(params);
|
||||||
|
String requestMappingPath = "/api/" + domain;
|
||||||
|
|
||||||
|
return """
|
||||||
|
package %s;
|
||||||
|
|
||||||
|
import %s.%s;
|
||||||
|
import %s.%s;
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.common.web.controller.BaseBypassController;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
%simport lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
%s%s%s%s
|
||||||
|
/**
|
||||||
|
* %s bypass API
|
||||||
|
* S&P Maritime API에서 데이터를 실시간 조회하여 그대로 반환
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("%s")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "%s", description = "%s bypass API")
|
||||||
|
public class %sController extends BaseBypassController {
|
||||||
|
|
||||||
|
private final %s %s;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "%s 조회",
|
||||||
|
description = "S&P API에서 %s 데이터를 요청하고 응답을 그대로 반환합니다."
|
||||||
|
)
|
||||||
|
%s
|
||||||
|
public ResponseEntity<ApiResponse<%s>> get%sData(%s) {
|
||||||
|
return execute(() -> %s.get%sData(%s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(
|
||||||
|
packageName,
|
||||||
|
dtoPackage, dtoClass,
|
||||||
|
servicePackage, serviceClass,
|
||||||
|
listImport,
|
||||||
|
pathVariableImport, requestParamImport, requestBodyImport,
|
||||||
|
mappingAnnotation.equals("@PostMapping") ? "" : "",
|
||||||
|
config.getDisplayName(),
|
||||||
|
requestMappingPath,
|
||||||
|
domainCap, config.getDisplayName(),
|
||||||
|
domainCap,
|
||||||
|
serviceClass, serviceField,
|
||||||
|
config.getDisplayName(), config.getDisplayName(),
|
||||||
|
mappingAnnotation + mappingPath,
|
||||||
|
responseGeneric, domainCap,
|
||||||
|
paramAnnotations.isEmpty() ? "" : paramAnnotations,
|
||||||
|
serviceField, domainCap, serviceCallArgs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch 메서드 호출 코드 생성
|
||||||
|
*/
|
||||||
|
private String buildFetchMethodCall(BypassApiConfig config, List<BypassApiParam> params,
|
||||||
|
boolean isList, boolean isPost) {
|
||||||
|
List<BypassApiParam> pathParams = params.stream()
|
||||||
|
.filter(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
|
||||||
|
.toList();
|
||||||
|
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 : pathParams) {
|
||||||
|
// PATH 파라미터는 path()에 직접 치환되므로 별도 처리 불필요
|
||||||
|
}
|
||||||
|
for (BypassApiParam p : queryParams) {
|
||||||
|
uriBuilder.append("\n .queryParam(\"%s\", %s)"
|
||||||
|
.formatted(p.getParamName(), p.getParamName()));
|
||||||
|
}
|
||||||
|
uriBuilder.append("\n .build()");
|
||||||
|
|
||||||
|
String fetchName;
|
||||||
|
if (isPost) {
|
||||||
|
fetchName = isList ? "fetchPostList" : "fetchPostOne";
|
||||||
|
BypassApiParam bodyParam = params.stream()
|
||||||
|
.filter(p -> "BODY".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
String bodyArg = bodyParam != null ? bodyParam.getParamName() : "null";
|
||||||
|
return "return %s(%s, %s);".formatted(fetchName, bodyArg, uriBuilder);
|
||||||
|
} else {
|
||||||
|
fetchName = isList ? "fetchGetList" : "fetchGetOne";
|
||||||
|
return "return %s(%s);".formatted(fetchName, uriBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메서드 파라미터 목록 생성 (Java 타입 + 이름)
|
||||||
|
*/
|
||||||
|
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(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller 메서드 파라미터 어노테이션 포함 목록 생성
|
||||||
|
*/
|
||||||
|
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 annotation;
|
||||||
|
String description = p.getDescription() != null ? p.getDescription() : p.getParamName();
|
||||||
|
switch (p.getParamIn().toUpperCase()) {
|
||||||
|
case "PATH" -> annotation = """
|
||||||
|
@Parameter(description = "%s")
|
||||||
|
@PathVariable %s %s""".formatted(description, toJavaType(p.getParamType()), p.getParamName());
|
||||||
|
case "BODY" -> annotation = """
|
||||||
|
@Parameter(description = "%s")
|
||||||
|
@RequestBody %s %s""".formatted(description, toJavaType(p.getParamType()), p.getParamName());
|
||||||
|
default -> {
|
||||||
|
String required = Boolean.TRUE.equals(p.getRequired()) ? "true" : "false";
|
||||||
|
annotation = """
|
||||||
|
@Parameter(description = "%s")
|
||||||
|
@RequestParam(required = %s) %s %s""".formatted(
|
||||||
|
description, required, toJavaType(p.getParamType()), p.getParamName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return annotation;
|
||||||
|
})
|
||||||
|
.collect(Collectors.joining(",\n "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @GetMapping / @PostMapping에 붙는 경로 생성 (PATH 파라미터가 있는 경우)
|
||||||
|
*/
|
||||||
|
private String buildMappingPath(List<BypassApiParam> params) {
|
||||||
|
List<BypassApiParam> pathParams = params.stream()
|
||||||
|
.filter(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
|
||||||
|
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
|
||||||
|
.toList();
|
||||||
|
if (pathParams.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String path = pathParams.stream()
|
||||||
|
.map(p -> "{" + p.getParamName() + "}")
|
||||||
|
.collect(Collectors.joining("/", "/", ""));
|
||||||
|
return "(\"%s\")".formatted(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일을 지정 경로에 씁니다.
|
||||||
|
*
|
||||||
|
* @param path 파일 경로
|
||||||
|
* @param content 파일 내용
|
||||||
|
* @param force 덮어쓰기 허용 여부
|
||||||
|
* @return 생성된 파일 경로
|
||||||
|
*/
|
||||||
|
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 capitalize(String s) {
|
||||||
|
if (s == null || s.isEmpty()) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* param_type → Java 타입 변환
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/main/java/com/snp/batch/service/BypassConfigService.java
Normal file
228
src/main/java/com/snp/batch/service/BypassConfigService.java
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
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.BypassFieldDto;
|
||||||
|
import com.snp.batch.global.dto.BypassParamDto;
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import com.snp.batch.global.model.BypassApiField;
|
||||||
|
import com.snp.batch.global.model.BypassApiParam;
|
||||||
|
import com.snp.batch.global.repository.BypassApiConfigRepository;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 목록 조회
|
||||||
|
*
|
||||||
|
* @return 전체 BYPASS API 설정 목록
|
||||||
|
*/
|
||||||
|
public List<BypassConfigResponse> getConfigs() {
|
||||||
|
return configRepository.findAll().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 상세 조회
|
||||||
|
*
|
||||||
|
* @param id 설정 ID
|
||||||
|
* @return 설정 상세 정보
|
||||||
|
*/
|
||||||
|
public BypassConfigResponse getConfig(Long id) {
|
||||||
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||||
|
return toResponse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 등록
|
||||||
|
*
|
||||||
|
* @param request 등록 요청 DTO
|
||||||
|
* @return 등록된 설정 정보
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public BypassConfigResponse createConfig(BypassConfigRequest request) {
|
||||||
|
if (configRepository.existsByDomainName(request.getDomainName())) {
|
||||||
|
throw new IllegalArgumentException("이미 존재하는 도메인입니다: " + request.getDomainName());
|
||||||
|
}
|
||||||
|
BypassApiConfig config = toEntity(request);
|
||||||
|
|
||||||
|
if (request.getParams() != null) {
|
||||||
|
request.getParams().forEach(p -> {
|
||||||
|
BypassApiParam param = toParamEntity(p);
|
||||||
|
param.setConfig(config);
|
||||||
|
config.getParams().add(param);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.getFields() != null) {
|
||||||
|
request.getFields().forEach(f -> {
|
||||||
|
BypassApiField field = toFieldEntity(f);
|
||||||
|
field.setConfig(config);
|
||||||
|
config.getFields().add(field);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return toResponse(configRepository.save(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 수정
|
||||||
|
*
|
||||||
|
* @param id 설정 ID
|
||||||
|
* @param request 수정 요청 DTO
|
||||||
|
* @return 수정된 설정 정보
|
||||||
|
*/
|
||||||
|
@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.setResponseType(request.getResponseType());
|
||||||
|
config.setDescription(request.getDescription());
|
||||||
|
|
||||||
|
// params 교체 (orphanRemoval로 기존 자동 삭제)
|
||||||
|
config.getParams().clear();
|
||||||
|
if (request.getParams() != null) {
|
||||||
|
request.getParams().forEach(p -> {
|
||||||
|
BypassApiParam param = toParamEntity(p);
|
||||||
|
param.setConfig(config);
|
||||||
|
config.getParams().add(param);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fields 교체
|
||||||
|
config.getFields().clear();
|
||||||
|
if (request.getFields() != null) {
|
||||||
|
request.getFields().forEach(f -> {
|
||||||
|
BypassApiField field = toFieldEntity(f);
|
||||||
|
field.setConfig(config);
|
||||||
|
config.getFields().add(field);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResponse(configRepository.save(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 삭제
|
||||||
|
*
|
||||||
|
* @param id 설정 ID
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteConfig(Long id) {
|
||||||
|
if (!configRepository.existsById(id)) {
|
||||||
|
throw new IllegalArgumentException("설정을 찾을 수 없습니다: " + id);
|
||||||
|
}
|
||||||
|
configRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 생성 완료 마킹
|
||||||
|
*
|
||||||
|
* @param id 설정 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())
|
||||||
|
.displayName(config.getDisplayName())
|
||||||
|
.webclientBean(config.getWebclientBean())
|
||||||
|
.externalPath(config.getExternalPath())
|
||||||
|
.httpMethod(config.getHttpMethod())
|
||||||
|
.responseType(config.getResponseType())
|
||||||
|
.description(config.getDescription())
|
||||||
|
.generated(config.getGenerated())
|
||||||
|
.generatedAt(config.getGeneratedAt())
|
||||||
|
.createdAt(config.getCreatedAt())
|
||||||
|
.updatedAt(config.getUpdatedAt())
|
||||||
|
.params(config.getParams().stream().map(this::toParamDto).toList())
|
||||||
|
.fields(config.getFields().stream().map(this::toFieldDto).toList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassApiConfig toEntity(BypassConfigRequest request) {
|
||||||
|
return BypassApiConfig.builder()
|
||||||
|
.domainName(request.getDomainName())
|
||||||
|
.displayName(request.getDisplayName())
|
||||||
|
.webclientBean(request.getWebclientBean())
|
||||||
|
.externalPath(request.getExternalPath())
|
||||||
|
.httpMethod(request.getHttpMethod() != null ? request.getHttpMethod() : "GET")
|
||||||
|
.responseType(request.getResponseType() != null ? request.getResponseType() : "LIST")
|
||||||
|
.description(request.getDescription())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassApiField toFieldEntity(BypassFieldDto dto) {
|
||||||
|
return BypassApiField.builder()
|
||||||
|
.fieldName(dto.getFieldName())
|
||||||
|
.jsonProperty(dto.getJsonProperty())
|
||||||
|
.fieldType(dto.getFieldType() != null ? dto.getFieldType() : "String")
|
||||||
|
.description(dto.getDescription())
|
||||||
|
.sortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0)
|
||||||
|
.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())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassFieldDto toFieldDto(BypassApiField field) {
|
||||||
|
return BypassFieldDto.builder()
|
||||||
|
.id(field.getId())
|
||||||
|
.fieldName(field.getFieldName())
|
||||||
|
.jsonProperty(field.getJsonProperty())
|
||||||
|
.fieldType(field.getFieldType())
|
||||||
|
.description(field.getDescription())
|
||||||
|
.sortOrder(field.getSortOrder())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/main/java/com/snp/batch/service/JsonSchemaParser.java
Normal file
114
src/main/java/com/snp/batch/service/JsonSchemaParser.java
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package com.snp.batch.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.snp.batch.global.dto.BypassFieldDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 샘플 문자열에서 BypassFieldDto 목록을 추출하는 유틸리티 서비스.
|
||||||
|
* 1단계 깊이만 파싱하며, 중첩 객체는 Object 타입으로 처리합니다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class JsonSchemaParser {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public JsonSchemaParser(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 샘플 문자열에서 필드 목록을 추출합니다.
|
||||||
|
* 배열이면 첫 번째 요소를 파싱합니다.
|
||||||
|
* 1단계 깊이만 파싱하며, 중첩 객체는 Object 타입으로 처리합니다.
|
||||||
|
*
|
||||||
|
* @param jsonSample JSON 샘플 문자열
|
||||||
|
* @return 필드 목록 (파싱 실패 시 빈 목록 반환)
|
||||||
|
*/
|
||||||
|
public List<BypassFieldDto> parse(String jsonSample) {
|
||||||
|
List<BypassFieldDto> result = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(jsonSample);
|
||||||
|
|
||||||
|
// 배열이면 첫 번째 요소 추출
|
||||||
|
JsonNode target = root.isArray() ? root.get(0) : root;
|
||||||
|
if (target == null || !target.isObject()) {
|
||||||
|
log.warn("JSON 파싱: 유효한 객체를 찾을 수 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sortOrder = 0;
|
||||||
|
var fields = target.fields();
|
||||||
|
while (fields.hasNext()) {
|
||||||
|
var entry = fields.next();
|
||||||
|
String originalKey = entry.getKey();
|
||||||
|
String fieldName = toCamelCase(originalKey);
|
||||||
|
String fieldType = inferType(entry.getValue());
|
||||||
|
|
||||||
|
// jsonProperty: fieldName과 원본 키가 다를 때만 설정
|
||||||
|
String jsonProperty = fieldName.equals(originalKey) ? null : originalKey;
|
||||||
|
|
||||||
|
result.add(BypassFieldDto.builder()
|
||||||
|
.fieldName(fieldName)
|
||||||
|
.jsonProperty(jsonProperty)
|
||||||
|
.fieldType(fieldType)
|
||||||
|
.sortOrder(sortOrder++)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("JSON 샘플 파싱 실패: {}", e.getMessage(), e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PascalCase → camelCase 변환 헬퍼
|
||||||
|
*/
|
||||||
|
private String toCamelCase(String name) {
|
||||||
|
if (name == null || name.isEmpty()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
if (Character.isUpperCase(name.charAt(0))) {
|
||||||
|
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JsonNode에서 Java 타입 추론
|
||||||
|
*/
|
||||||
|
private String inferType(JsonNode node) {
|
||||||
|
if (node.isTextual()) {
|
||||||
|
return "String";
|
||||||
|
}
|
||||||
|
if (node.isInt()) {
|
||||||
|
return "Integer";
|
||||||
|
}
|
||||||
|
if (node.isLong() || (node.isNumber() && node.longValue() > Integer.MAX_VALUE)) {
|
||||||
|
return "Long";
|
||||||
|
}
|
||||||
|
if (node.isDouble() || node.isFloat()) {
|
||||||
|
return "Double";
|
||||||
|
}
|
||||||
|
if (node.isBoolean()) {
|
||||||
|
return "Boolean";
|
||||||
|
}
|
||||||
|
if (node.isNull()) {
|
||||||
|
return "String";
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
return "List<Object>";
|
||||||
|
}
|
||||||
|
if (node.isObject()) {
|
||||||
|
return "Object";
|
||||||
|
}
|
||||||
|
return "String";
|
||||||
|
}
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user