From 951b6c759d9e82b208bb9578a48797f3dd86d94d Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Thu, 26 Mar 2026 16:24:54 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20BYPASS=20API=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=9C=EB=B0=9C=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 베이스 클래스 추가 (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) --- frontend/src/App.tsx | 2 + frontend/src/api/bypassApi.ts | 123 ++++++ frontend/src/components/Navbar.tsx | 1 + .../components/bypass/BypassConfigModal.tsx | 239 ++++++++++ .../src/components/bypass/BypassStepBasic.tsx | 168 +++++++ .../components/bypass/BypassStepFields.tsx | 233 ++++++++++ .../components/bypass/BypassStepParams.tsx | 146 ++++++ frontend/src/pages/BypassConfig.tsx | 279 ++++++++++++ .../web/controller/BaseBypassController.java | 28 ++ .../common/web/service/BaseBypassService.java | 101 +++++ .../controller/BypassConfigController.java | 133 ++++++ .../batch/global/dto/BypassConfigRequest.java | 45 ++ .../global/dto/BypassConfigResponse.java | 60 +++ .../snp/batch/global/dto/BypassFieldDto.java | 33 ++ .../snp/batch/global/dto/BypassParamDto.java | 36 ++ .../global/dto/CodeGenerationResult.java | 28 ++ .../batch/global/model/BypassApiConfig.java | 135 ++++++ .../batch/global/model/BypassApiField.java | 69 +++ .../batch/global/model/BypassApiParam.java | 77 ++++ .../repository/BypassApiConfigRepository.java | 25 ++ .../repository/BypassApiFieldRepository.java | 25 ++ .../repository/BypassApiParamRepository.java | 25 ++ .../web/risk/controller/RiskController.java | 23 +- .../web/risk/service/RiskBypassService.java | 37 +- .../batch/service/BypassCodeGenerator.java | 415 ++++++++++++++++++ .../batch/service/BypassConfigService.java | 228 ++++++++++ .../snp/batch/service/JsonSchemaParser.java | 114 +++++ 27 files changed, 2781 insertions(+), 47 deletions(-) create mode 100644 frontend/src/api/bypassApi.ts create mode 100644 frontend/src/components/bypass/BypassConfigModal.tsx create mode 100644 frontend/src/components/bypass/BypassStepBasic.tsx create mode 100644 frontend/src/components/bypass/BypassStepFields.tsx create mode 100644 frontend/src/components/bypass/BypassStepParams.tsx create mode 100644 frontend/src/pages/BypassConfig.tsx create mode 100644 src/main/java/com/snp/batch/common/web/controller/BaseBypassController.java create mode 100644 src/main/java/com/snp/batch/common/web/service/BaseBypassService.java create mode 100644 src/main/java/com/snp/batch/global/controller/BypassConfigController.java create mode 100644 src/main/java/com/snp/batch/global/dto/BypassConfigRequest.java create mode 100644 src/main/java/com/snp/batch/global/dto/BypassConfigResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/BypassFieldDto.java create mode 100644 src/main/java/com/snp/batch/global/dto/BypassParamDto.java create mode 100644 src/main/java/com/snp/batch/global/dto/CodeGenerationResult.java create mode 100644 src/main/java/com/snp/batch/global/model/BypassApiConfig.java create mode 100644 src/main/java/com/snp/batch/global/model/BypassApiField.java create mode 100644 src/main/java/com/snp/batch/global/model/BypassApiParam.java create mode 100644 src/main/java/com/snp/batch/global/repository/BypassApiConfigRepository.java create mode 100644 src/main/java/com/snp/batch/global/repository/BypassApiFieldRepository.java create mode 100644 src/main/java/com/snp/batch/global/repository/BypassApiParamRepository.java create mode 100644 src/main/java/com/snp/batch/service/BypassCodeGenerator.java create mode 100644 src/main/java/com/snp/batch/service/BypassConfigService.java create mode 100644 src/main/java/com/snp/batch/service/JsonSchemaParser.java diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa9d191..7e28c43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/api/bypassApi.ts b/frontend/src/api/bypassApi.ts new file mode 100644 index 0000000..62ca2cc --- /dev/null +++ b/frontend/src/api/bypassApi.ts @@ -0,0 +1,123 @@ +// API 응답 타입 +interface ApiResponse { + 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 + 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(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +async function postJson(url: string, body?: unknown): Promise { + 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(url: string, body?: unknown): Promise { + 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(url: string): Promise { + 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>(BASE), + getConfig: (id: number) => + fetchJson>(`${BASE}/${id}`), + createConfig: (data: BypassConfigRequest) => + postJson>(BASE, data), + updateConfig: (id: number, data: BypassConfigRequest) => + putJson>(`${BASE}/${id}`, data), + deleteConfig: (id: number) => + deleteJson>(`${BASE}/${id}`), + generateCode: (id: number, force = false) => + postJson>(`${BASE}/${id}/generate?force=${force}`), + parseJson: (jsonSample: string) => + postJson>(`${BASE}/parse-json`, jsonSample), + getWebclientBeans: () => + fetchJson>(`${BASE}/webclient-beans`), +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index fa9be55..b04af2d 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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() { diff --git a/frontend/src/components/bypass/BypassConfigModal.tsx b/frontend/src/components/bypass/BypassConfigModal.tsx new file mode 100644 index 0000000..edec66b --- /dev/null +++ b/frontend/src/components/bypass/BypassConfigModal.tsx @@ -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; + onClose: () => void; +} + +type StepNumber = 1 | 2 | 3; + +const STEP_LABELS: Record = { + 1: '기본 정보', + 2: '파라미터', + 3: 'DTO 필드', +}; + +const DEFAULT_FORM: Omit = { + domainName: '', + displayName: '', + webclientBean: '', + externalPath: '', + httpMethod: 'GET', + responseType: 'LIST', + description: '', +}; + +export default function BypassConfigModal({ + open, + editConfig, + webclientBeans, + onSave, + onClose, +}: BypassConfigModalProps) { + const [step, setStep] = useState(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([]); + const [fields, setFields] = useState([]); + 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 ( +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+

+ {editConfig ? 'Bypass API 수정' : 'Bypass API 등록'} +

+ + {/* 스텝 인디케이터 */} +
+ {steps.map((s, idx) => ( +
+
+ s + ? 'bg-wing-accent/30 text-wing-accent' + : 'bg-wing-card text-wing-muted border border-wing-border', + ].join(' ')} + > + {s} + + + {STEP_LABELS[s]} + +
+ {idx < steps.length - 1 && ( +
+ )} +
+ ))} +
+
+ + {/* 본문 */} +
+ {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} +
+ + {/* 하단 버튼 */} +
+
+ {step > 1 && ( + + )} +
+
+ {step === 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/bypass/BypassStepBasic.tsx b/frontend/src/components/bypass/BypassStepBasic.tsx new file mode 100644 index 0000000..969450f --- /dev/null +++ b/frontend/src/components/bypass/BypassStepBasic.tsx @@ -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 ( +
+

+ BYPASS API의 기본 정보를 입력하세요. 도메인명을 기반으로 코드가 생성됩니다. +

+ +
+ {/* 도메인명 */} +
+ + 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(' ')} + /> +

영문 소문자/숫자 조합 (수정 불가)

+
+ + {/* 표시명 */} +
+ + 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" + /> +
+ + {/* WebClient */} +
+ + +
+ + {/* 외부 API 경로 */} +
+ + 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" + /> +
+ + {/* HTTP 메서드 */} +
+ +
+ {['GET', 'POST'].map((method) => ( + + ))} +
+
+ + {/* 응답 타입 */} +
+ +
+ {['LIST', 'OBJECT'].map((type) => ( + + ))} +
+
+ + {/* 설명 */} +
+ +