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 Schedules = lazy(() => import('./pages/Schedules'));
|
||||
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
|
||||
|
||||
function AppLayout() {
|
||||
const { toasts, removeToast } = useToastContext();
|
||||
@ -32,6 +33,7 @@ function AppLayout() {
|
||||
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||
<Route path="/bypass-config" element={<BypassConfig />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</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: '/schedules', label: '스케줄', icon: '🕐' },
|
||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||
{ path: '/bypass-config', label: 'Bypass API', icon: '🔗' },
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
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.web.risk.service.RiskBypassService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -21,12 +20,11 @@ import java.util.List;
|
||||
* Risk 상세 조회 bypass API
|
||||
* S&P Maritime API에서 Risk 데이터를 실시간 조회하여 그대로 반환
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/risk")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Risk", description = "선박 Risk 상세 정보 bypass API")
|
||||
public class RiskController {
|
||||
public class RiskController extends BaseBypassController {
|
||||
|
||||
private final RiskBypassService riskBypassService;
|
||||
|
||||
@ -37,20 +35,7 @@ public class RiskController {
|
||||
@GetMapping("/{imo}")
|
||||
public ResponseEntity<ApiResponse<List<RiskDto>>> getRiskDetailByImo(
|
||||
@Parameter(description = "IMO 번호", example = "9321483")
|
||||
@PathVariable String imo
|
||||
) {
|
||||
try {
|
||||
List<RiskDto> riskData = riskBypassService.getRiskDetailByImo(imo);
|
||||
return ResponseEntity.ok(ApiResponse.success(riskData));
|
||||
} catch (WebClientResponseException e) {
|
||||
log.error("S&P Risk API 호출 실패 - IMO: {}, status: {}, body: {}",
|
||||
imo, e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return ResponseEntity.status(e.getStatusCode())
|
||||
.body(ApiResponse.error("S&P API 호출 실패: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Risk 상세 조회 중 오류 - IMO: {}", imo, e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResponse.error("Risk 조회 실패: " + e.getMessage()));
|
||||
}
|
||||
@PathVariable String imo) {
|
||||
return execute(() -> riskBypassService.getRiskDetailByImo(imo));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
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 lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -13,18 +13,14 @@ import java.util.List;
|
||||
* S&P Risk API bypass 서비스
|
||||
* 외부 Maritime API에서 Risk 상세 데이터를 실시간 조회하여 그대로 반환
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RiskBypassService {
|
||||
|
||||
private static final String RISK_API_PATH = "/RiskAndCompliance/RisksByImos";
|
||||
|
||||
private final WebClient webClient;
|
||||
public class RiskBypassService extends BaseBypassService<RiskDto> {
|
||||
|
||||
public RiskBypassService(
|
||||
@Qualifier("maritimeServiceApiWebClient") WebClient webClient
|
||||
) {
|
||||
this.webClient = webClient;
|
||||
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
|
||||
super(webClient, "/RiskAndCompliance/RisksByImos", "S&P Risk",
|
||||
new ParameterizedTypeReference<>() {},
|
||||
new ParameterizedTypeReference<>() {});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -34,23 +30,8 @@ public class RiskBypassService {
|
||||
* @return Risk 상세 데이터 목록
|
||||
*/
|
||||
public List<RiskDto> getRiskDetailByImo(String imo) {
|
||||
log.info("S&P Risk API 호출 - IMO: {}", imo);
|
||||
|
||||
List<RiskDto> response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path(RISK_API_PATH)
|
||||
return fetchGetList(uri -> uri.path(getApiPath())
|
||||
.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;
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
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