feat: BY PASS API 등록 프로세스 설계 및 개발 (#63) #108

병합
HYOJIN feature/ISSUE-63-bypass-api-registration 에서 develop 로 19 commits 를 머지했습니다 2026-03-27 14:32:14 +09:00
27개의 변경된 파일2781개의 추가작업 그리고 47개의 파일을 삭제
Showing only changes of commit 951b6c759d - Show all commits

파일 보기

@ -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>

파일 보기

@ -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() {

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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();
}
}

파일 보기

@ -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;
}

파일 보기

@ -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)
.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;
return fetchGetList(uri -> uri.path(getApiPath())
.queryParam("imos", imo)
.build());
}
}

파일 보기

@ -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";
};
}
}

파일 보기

@ -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();
}
}

파일 보기

@ -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";
}
}