feat: S&P Bypass 피드백 반영 (#123)

- Response JSON 원본 반환 (ApiResponse 래핑 제거, executeRaw 추가)
- 메뉴명 변경: Bypass API → API 관리
- 사용자용 API 카탈로그 페이지 (/bypass-catalog) 추가
- 운영 환경 코드 생성 차단 (app.environment=prod 시 비활성화)
- Bypass API 코드 생성 (compliance, risk 도메인)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-04-01 08:35:07 +09:00
부모 bfd86c9eff
커밋 b3d9938422
16개의 변경된 파일562개의 추가작업 그리고 11개의 파일을 삭제

파일 보기

@ -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' },
],
},
{

파일 보기

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