feat: S&P Bypass 피드백 반영 (#123) #129
@ -16,6 +16,7 @@ const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
|
||||
const Schedules = lazy(() => import('./pages/Schedules'));
|
||||
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
|
||||
const BypassCatalog = lazy(() => import('./pages/BypassCatalog'));
|
||||
const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
|
||||
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
|
||||
|
||||
@ -51,6 +52,7 @@ function AppLayout() {
|
||||
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||
<Route path="/bypass-catalog" element={<BypassCatalog />} />
|
||||
<Route path="/bypass-config" element={<BypassConfig />} />
|
||||
<Route path="/screening-guide" element={<ScreeningGuide />} />
|
||||
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
|
||||
|
||||
@ -45,9 +45,10 @@ const MENU_STRUCTURE: MenuSection[] = [
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</svg>
|
||||
),
|
||||
defaultPath: '/bypass-config',
|
||||
defaultPath: '/bypass-catalog',
|
||||
children: [
|
||||
{ id: 'bypass-config', label: 'Bypass API', path: '/bypass-config' },
|
||||
{ id: 'bypass-catalog', label: 'API 카탈로그', path: '/bypass-catalog' },
|
||||
{ id: 'bypass-config', label: 'API 관리', path: '/bypass-config' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
311
frontend/src/pages/BypassCatalog.tsx
Normal file
311
frontend/src/pages/BypassCatalog.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
interface BypassParam {
|
||||
paramName: string;
|
||||
paramType: string;
|
||||
paramIn: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
interface BypassConfig {
|
||||
id: number;
|
||||
domainName: string;
|
||||
endpointName: string;
|
||||
displayName: string;
|
||||
httpMethod: string;
|
||||
externalPath: string;
|
||||
description: string;
|
||||
generated: boolean;
|
||||
createdAt: string;
|
||||
params: BypassParam[];
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
type ViewMode = 'card' | 'table';
|
||||
|
||||
const 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',
|
||||
};
|
||||
|
||||
const SWAGGER_URL = '/snp-api/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API';
|
||||
|
||||
export default function BypassCatalog() {
|
||||
const [configs, setConfigs] = useState<BypassConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedDomain, setSelectedDomain] = useState('');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/snp-api/api/bypass-config')
|
||||
.then(res => res.json())
|
||||
.then((res: ApiResponse<BypassConfig[]>) => setConfigs((res.data ?? []).filter(c => c.generated)))
|
||||
.catch(() => setConfigs([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const domainNames = useMemo(() => {
|
||||
const names = [...new Set(configs.map((c) => c.domainName))];
|
||||
return names.sort();
|
||||
}, [configs]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return configs.filter((c) => {
|
||||
const matchesSearch =
|
||||
!searchTerm.trim() ||
|
||||
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(c.description || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
|
||||
return matchesSearch && matchesDomain;
|
||||
});
|
||||
}, [configs, searchTerm, selectedDomain]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||
<div className="text-sm">API 목록을 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
등록된 Bypass API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={SWAGGER_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors no-underline"
|
||||
>
|
||||
Swagger UI
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 검색 + 필터 + 뷰 전환 */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
{/* 검색 */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="도메인명, 표시명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none bg-wing-surface text-wing-text"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 도메인 드롭다운 필터 */}
|
||||
<select
|
||||
value={selectedDomain}
|
||||
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface text-wing-text"
|
||||
>
|
||||
<option value="">전체 도메인</option>
|
||||
{domainNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* 뷰 전환 토글 */}
|
||||
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
title="테이블 보기"
|
||||
className={`px-3 py-2 transition-colors ${
|
||||
viewMode === 'table'
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('card')}
|
||||
title="카드 보기"
|
||||
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||
viewMode === 'card'
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(searchTerm || selectedDomain) && (
|
||||
<p className="mt-2 text-xs text-wing-muted">
|
||||
{filtered.length}개 API 검색됨
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{configs.length === 0 ? (
|
||||
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||
<p className="text-base font-medium mb-1">등록된 API가 없습니다.</p>
|
||||
<p className="text-sm">관리자에게 문의해주세요.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||
<p className="text-base font-medium mb-1">검색 결과가 없습니다.</p>
|
||||
<p className="text-sm">다른 검색어를 사용해 보세요.</p>
|
||||
</div>
|
||||
) : viewMode === 'card' ? (
|
||||
/* 카드 뷰 */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.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-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
|
||||
{config.httpMethod}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-wing-muted font-mono truncate">{config.externalPath}</p>
|
||||
{config.description && (
|
||||
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{config.params.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] text-wing-muted mb-1 font-medium">Parameters</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{config.params.map((p) => (
|
||||
<span
|
||||
key={p.paramName}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted"
|
||||
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
|
||||
>
|
||||
{p.paramName}
|
||||
{p.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1 border-t border-wing-border mt-auto">
|
||||
<a
|
||||
href={SWAGGER_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
||||
>
|
||||
Swagger에서 테스트 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 테이블 뷰 */
|
||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-wing-border bg-wing-card">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">도메인명</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">표시명</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">HTTP</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">외부 경로</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">파라미터</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Swagger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-wing-border">
|
||||
{filtered.map((config) => (
|
||||
<tr key={config.id} className="hover:bg-wing-hover transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-wing-text">{config.domainName}</td>
|
||||
<td className="px-4 py-3 font-medium text-wing-text">{config.displayName}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={['px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
|
||||
{config.httpMethod}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[250px] truncate" title={config.externalPath}>
|
||||
{config.externalPath}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{config.params.map((p) => (
|
||||
<span
|
||||
key={p.paramName}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-card text-wing-muted"
|
||||
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
|
||||
>
|
||||
{p.paramName}
|
||||
{p.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<a
|
||||
href={SWAGGER_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
||||
>
|
||||
테스트 →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -41,6 +41,7 @@ export default function BypassConfig() {
|
||||
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [generationResult, setGenerationResult] = useState<CodeGenerationResult | null>(null);
|
||||
const [codeGenEnabled, setCodeGenEnabled] = useState(true);
|
||||
|
||||
const loadConfigs = useCallback(async () => {
|
||||
try {
|
||||
@ -59,6 +60,10 @@ export default function BypassConfig() {
|
||||
bypassApi.getWebclientBeans()
|
||||
.then((res) => setWebclientBeans(res.data ?? []))
|
||||
.catch((err) => console.error(err));
|
||||
fetch('/snp-api/api/bypass-config/environment')
|
||||
.then(res => res.json())
|
||||
.then(res => setCodeGenEnabled(res.data?.codeGenerationEnabled ?? true))
|
||||
.catch(() => {});
|
||||
}, [loadConfigs]);
|
||||
|
||||
const handleCreate = () => {
|
||||
@ -314,7 +319,13 @@ export default function BypassConfig() {
|
||||
<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"
|
||||
disabled={!codeGenEnabled}
|
||||
title={!codeGenEnabled ? '운영 환경에서는 코드 생성이 불가합니다' : ''}
|
||||
className={`flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
codeGenEnabled
|
||||
? 'text-white bg-wing-accent hover:bg-wing-accent/80'
|
||||
: 'text-wing-muted bg-wing-card cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{config.generated ? '재생성' : '코드 생성'}
|
||||
</button>
|
||||
@ -416,7 +427,13 @@ export default function BypassConfig() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmAction({ type: 'generate', config })}
|
||||
className="px-3 py-1.5 text-xs font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||
disabled={!codeGenEnabled}
|
||||
title={!codeGenEnabled ? '운영 환경에서는 코드 생성이 불가합니다' : ''}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
codeGenEnabled
|
||||
? 'text-white bg-wing-accent hover:bg-wing-accent/80'
|
||||
: 'text-wing-muted bg-wing-card cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{config.generated ? '재생성' : '코드 생성'}
|
||||
</button>
|
||||
|
||||
@ -15,7 +15,7 @@ const sections = [
|
||||
title: 'S&P Bypass',
|
||||
description: 'S&P Bypass API 관리',
|
||||
detail: 'API 등록, 코드 생성 관리, 테스트',
|
||||
path: '/bypass-config',
|
||||
path: '/bypass-catalog',
|
||||
icon: '🔗',
|
||||
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||
menuCount: 1,
|
||||
|
||||
@ -25,4 +25,21 @@ public abstract class BaseBypassController {
|
||||
.body(ApiResponse.error("처리 실패: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 응답을 ApiResponse 래핑 없이 원본 그대로 반환
|
||||
*/
|
||||
protected <T> ResponseEntity<T> executeRaw(Supplier<T> action) {
|
||||
try {
|
||||
T result = action.get();
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (WebClientResponseException e) {
|
||||
log.error("외부 API 호출 실패 - status: {}, body: {}",
|
||||
e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return ResponseEntity.status(e.getStatusCode()).body(null);
|
||||
} catch (Exception e) {
|
||||
log.error("API 처리 중 오류", e);
|
||||
return ResponseEntity.internalServerError().body(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ 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.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -40,6 +41,9 @@ public class BypassConfigController {
|
||||
private final BypassCodeGenerator bypassCodeGenerator;
|
||||
private final BypassApiConfigRepository configRepository;
|
||||
|
||||
@Value("${app.environment:dev}")
|
||||
private String environment;
|
||||
|
||||
@Operation(summary = "설정 목록 조회")
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<BypassConfigResponse>>> getConfigs() {
|
||||
@ -82,6 +86,10 @@ public class BypassConfigController {
|
||||
public ResponseEntity<ApiResponse<CodeGenerationResult>> generateCode(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "false") boolean force) {
|
||||
if ("prod".equals(environment)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("운영 환경에서는 코드 생성이 불가합니다. 개발 환경에서 생성 후 배포해주세요."));
|
||||
}
|
||||
try {
|
||||
BypassApiConfig config = configRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||
@ -101,6 +109,15 @@ public class BypassConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "환경 정보", description = "현재 서버 환경 정보를 반환합니다 (dev/prod).")
|
||||
@GetMapping("/environment")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getEnvironment() {
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
"environment", environment,
|
||||
"codeGenerationEnabled", !"prod".equals(environment)
|
||||
)));
|
||||
}
|
||||
|
||||
@Operation(summary = "WebClient 빈 목록", description = "사용 가능한 WebClient 빈 이름 목록을 반환합니다.")
|
||||
@GetMapping("/webclient-beans")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, String>>>> getWebclientBeans() {
|
||||
|
||||
@ -15,10 +15,10 @@ public class WebViewController {
|
||||
@GetMapping({"/", "/dashboard", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||
"/recollects", "/recollects/{id:\\d+}",
|
||||
"/schedules", "/schedule-timeline", "/monitoring",
|
||||
"/bypass-config", "/screening-guide", "/risk-compliance-history",
|
||||
"/bypass-catalog", "/bypass-config", "/screening-guide", "/risk-compliance-history",
|
||||
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
|
||||
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
|
||||
"/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
|
||||
"/bypass-catalog/**", "/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
|
||||
public String forward() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package com.snp.batch.jobs.web.compliance.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import com.snp.batch.jobs.web.compliance.service.CompliancesByImosService;
|
||||
|
||||
/**
|
||||
* Compliance bypass API
|
||||
* S&P Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/compliance")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Compliance", description = "[Service API] Compliance bypass API")
|
||||
public class ComplianceController extends BaseBypassController {
|
||||
|
||||
private final CompliancesByImosService compliancesByImosService;
|
||||
|
||||
@Operation(
|
||||
summary = "IMO 기반 선박 규정준수 조회",
|
||||
description = "Gets details of the IMOs of ships with full compliance details that match given IMOs"
|
||||
)
|
||||
@GetMapping("/CompliancesByImos")
|
||||
public ResponseEntity<JsonNode> getCompliancesByImosData(@Parameter(description = "Comma separated IMOs up to a total of 100", example = "9876543")
|
||||
@RequestParam(required = true) String imos) {
|
||||
return executeRaw(() -> compliancesByImosService.getCompliancesByImosData(imos));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.snp.batch.jobs.web.compliance.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.snp.batch.common.web.service.BaseBypassService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* IMO 기반 선박 규정준수 조회 bypass 서비스
|
||||
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
|
||||
*/
|
||||
@Service
|
||||
public class CompliancesByImosService extends BaseBypassService<JsonNode> {
|
||||
|
||||
public CompliancesByImosService(
|
||||
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
|
||||
super(webClient, "/RiskAndCompliance/CompliancesByImos", "IMO 기반 선박 규정준수 조회",
|
||||
new ParameterizedTypeReference<>() {},
|
||||
new ParameterizedTypeReference<>() {});
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO 기반 선박 규정준수 조회 데이터를 조회합니다.
|
||||
*/
|
||||
public JsonNode getCompliancesByImosData(String imos) {
|
||||
return fetchRawGet(uri -> uri.path(getApiPath())
|
||||
.queryParam("imos", imos)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.snp.batch.jobs.web.risk.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import com.snp.batch.jobs.web.risk.service.RisksByImosService;
|
||||
import com.snp.batch.jobs.web.risk.service.UpdatedComplianceListService;
|
||||
|
||||
/**
|
||||
* Risk bypass API
|
||||
* S&P Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/risk")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Risk", description = "[Service API] Risk bypass API")
|
||||
public class RiskController extends BaseBypassController {
|
||||
|
||||
private final RisksByImosService risksByImosService;
|
||||
private final UpdatedComplianceListService updatedComplianceListService;
|
||||
|
||||
@Operation(
|
||||
summary = "IMO 기반 선박 위험지표 조회",
|
||||
description = "Gets details of the IMOs of all ships with risk updates as a collection"
|
||||
)
|
||||
@GetMapping("/RisksByImos")
|
||||
public ResponseEntity<JsonNode> getRisksByImosData(@Parameter(description = "Comma separated IMOs up to a total of 100", example = "9876543")
|
||||
@RequestParam(required = true) String imos) {
|
||||
return executeRaw(() -> risksByImosService.getRisksByImosData(imos));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "기간 내 변경된 위험지표 조회",
|
||||
description = "Gets details of the IMOs of all ships with compliance updates"
|
||||
)
|
||||
@GetMapping("/UpdatedComplianceList")
|
||||
public ResponseEntity<JsonNode> getUpdatedComplianceListData(@Parameter(description = "Time/seconds are optional", example = "2026-03-30T07:01:27.000Z")
|
||||
@RequestParam(required = true) String fromDate,
|
||||
@Parameter(description = "Time/seconds are optional. If unspecified, the current UTC date and time is used", example = "2026-03-31T07:01:27.000Z")
|
||||
@RequestParam(required = true) String toDate) {
|
||||
return executeRaw(() -> updatedComplianceListService.getUpdatedComplianceListData(fromDate, toDate));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.snp.batch.jobs.web.risk.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.snp.batch.common.web.service.BaseBypassService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* IMO 기반 선박 위험지표 조회 bypass 서비스
|
||||
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
|
||||
*/
|
||||
@Service
|
||||
public class RisksByImosService extends BaseBypassService<JsonNode> {
|
||||
|
||||
public RisksByImosService(
|
||||
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
|
||||
super(webClient, "/RiskAndCompliance/RisksByImos", "IMO 기반 선박 위험지표 조회",
|
||||
new ParameterizedTypeReference<>() {},
|
||||
new ParameterizedTypeReference<>() {});
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO 기반 선박 위험지표 조회 데이터를 조회합니다.
|
||||
*/
|
||||
public JsonNode getRisksByImosData(String imos) {
|
||||
return fetchRawGet(uri -> uri.path(getApiPath())
|
||||
.queryParam("imos", imos)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.snp.batch.jobs.web.risk.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.snp.batch.common.web.service.BaseBypassService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* 기간 내 변경된 위험지표 조회 bypass 서비스
|
||||
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
|
||||
*/
|
||||
@Service
|
||||
public class UpdatedComplianceListService extends BaseBypassService<JsonNode> {
|
||||
|
||||
public UpdatedComplianceListService(
|
||||
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
|
||||
super(webClient, "/RiskAndCompliance/UpdatedComplianceList", "기간 내 변경된 위험지표 조회",
|
||||
new ParameterizedTypeReference<>() {},
|
||||
new ParameterizedTypeReference<>() {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간 내 변경된 위험지표 조회 데이터를 조회합니다.
|
||||
*/
|
||||
public JsonNode getUpdatedComplianceListData(String fromDate, String toDate) {
|
||||
return fetchRawGet(uri -> uri.path(getApiPath())
|
||||
.queryParam("fromDate", fromDate)
|
||||
.queryParam("toDate", toDate)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@ -131,7 +131,7 @@ public class BypassCodeGenerator {
|
||||
|
||||
/**
|
||||
* Controller 코드 생성 (RAW 모드).
|
||||
* 모든 엔드포인트가 ResponseEntity<ApiResponse<JsonNode>>를 반환합니다.
|
||||
* 모든 엔드포인트가 ResponseEntity<JsonNode>를 반환합니다 (외부 API 원본 JSON 그대로).
|
||||
*/
|
||||
private String generateControllerCode(String domain, List<BypassApiConfig> configs) {
|
||||
String packageName = BASE_PACKAGE + "." + domain + ".controller";
|
||||
@ -142,7 +142,6 @@ public class BypassCodeGenerator {
|
||||
// imports (중복 제거)
|
||||
Set<String> importSet = new LinkedHashSet<>();
|
||||
importSet.add("import com.fasterxml.jackson.databind.JsonNode;");
|
||||
importSet.add("import com.snp.batch.common.web.ApiResponse;");
|
||||
importSet.add("import com.snp.batch.common.web.controller.BaseBypassController;");
|
||||
importSet.add("import io.swagger.v3.oas.annotations.Operation;");
|
||||
importSet.add("import io.swagger.v3.oas.annotations.Parameter;");
|
||||
@ -207,12 +206,12 @@ public class BypassCodeGenerator {
|
||||
methods.append(" description = \"").append(opDescription).append("\"\n");
|
||||
methods.append(" )\n");
|
||||
methods.append(" ").append(mappingAnnotation).append(mappingPath).append("\n");
|
||||
methods.append(" public ResponseEntity<ApiResponse<JsonNode>> ").append(methodName).append("(");
|
||||
methods.append(" public ResponseEntity<JsonNode> ").append(methodName).append("(");
|
||||
if (!paramAnnotations.isEmpty()) {
|
||||
methods.append(paramAnnotations);
|
||||
}
|
||||
methods.append(") {\n");
|
||||
methods.append(" return execute(() -> ").append(serviceField)
|
||||
methods.append(" return executeRaw(() -> ").append(serviceField)
|
||||
.append(".").append(methodName).append("(").append(serviceCallArgs).append("));\n");
|
||||
methods.append(" }\n");
|
||||
}
|
||||
|
||||
@ -78,6 +78,7 @@ logging:
|
||||
|
||||
# Custom Application Properties
|
||||
app:
|
||||
environment: prod
|
||||
batch:
|
||||
chunk-size: 1000
|
||||
schedule:
|
||||
|
||||
@ -76,6 +76,7 @@ logging:
|
||||
|
||||
# Custom Application Properties
|
||||
app:
|
||||
environment: dev
|
||||
batch:
|
||||
chunk-size: 1000
|
||||
target-schema:
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user