From 0132408ae3d786d957ee3c26f0b3f74145d1f12a Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Thu, 26 Mar 2026 17:43:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Bypass=20API=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20Swagger=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트엔드: - DTO 필드 입력 폼에 필드 번호(#N) 및 총 카운트 표시 - List View(테이블 뷰) 추가 및 카드/테이블 뷰 전환 - 실시간 검색 기능 추가 (도메인명, 표시명) Swagger: - GroupedOpenApi로 그룹 분리 (Batch Management, Bypass Config, Bypass API) - 코드 생성 시 @Tag에 WebClient 종류 접두사 추가 - 코드 생성 시 @Parameter에 example 기본값 설정 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/bypass/BypassStepFields.tsx | 11 + frontend/src/pages/BypassConfig.tsx | 215 +++++++++++++++++- .../batch/global/config/SwaggerConfig.java | 26 +++ .../web/risk/controller/RiskController.java | 2 +- .../batch/service/BypassCodeGenerator.java | 39 +++- 5 files changed, 284 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/bypass/BypassStepFields.tsx b/frontend/src/components/bypass/BypassStepFields.tsx index db4b6d9..cacc8f3 100644 --- a/frontend/src/components/bypass/BypassStepFields.tsx +++ b/frontend/src/components/bypass/BypassStepFields.tsx @@ -110,9 +110,15 @@ export default function BypassStepFields({ fields, onChange }: BypassStepFieldsP ) : (
+
+ + 총 {fields.length}개 필드 + +
+ @@ -123,6 +129,11 @@ export default function BypassStepFields({ fields, onChange }: BypassStepFieldsP {fields.map((field, index) => ( +
# 필드명 JSON Property 타입
+ + #{index + 1} + + = { GET: 'bg-emerald-100 text-emerald-700', POST: 'bg-blue-100 text-blue-700', @@ -30,6 +32,8 @@ export default function BypassConfig() { const [configs, setConfigs] = useState([]); const [loading, setLoading] = useState(true); const [webclientBeans, setWebclientBeans] = useState([]); + const [viewMode, setViewMode] = useState('card'); + const [searchTerm, setSearchTerm] = useState(''); const [modalOpen, setModalOpen] = useState(false); const [editConfig, setEditConfig] = useState(null); @@ -106,6 +110,16 @@ export default function BypassConfig() { } }; + const filteredConfigs = useMemo(() => { + if (!searchTerm.trim()) return configs; + const term = searchTerm.toLowerCase(); + return configs.filter( + (c) => + c.domainName.toLowerCase().includes(term) || + c.displayName.toLowerCase().includes(term), + ); + }, [configs, searchTerm]); + if (loading) return ; return ( @@ -127,15 +141,102 @@ export default function BypassConfig() { - {/* 카드 그리드 */} + {/* 검색 + 뷰 전환 */} +
+
+ {/* 검색 */} +
+ + + + + + 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 && ( + + )} +
+ + {/* 뷰 전환 토글 */} +
+ + +
+
+ + {searchTerm && ( +

+ {filteredConfigs.length}개 API 검색됨 +

+ )} +
+ + {/* 빈 상태 */} {configs.length === 0 ? (

등록된 BYPASS API가 없습니다.

위 버튼을 눌러 새 API를 등록하세요.

- ) : ( + ) : filteredConfigs.length === 0 ? ( +
+

검색 결과가 없습니다.

+

다른 검색어를 사용해 보세요.

+
+ ) : viewMode === 'card' ? ( + /* 카드 뷰 */
- {configs.map((config) => ( + {filteredConfigs.map((config) => (
))}
+ ) : ( + /* 테이블 뷰 */ +
+
+ + + + + + + + + + + + + + + {filteredConfigs.map((config) => ( + + + + + + + + + + + ))} + +
+ 도메인명 + + 표시명 + + HTTP 메서드 + + WebClient + + 외부 경로 + + 생성 상태 + + 등록일 + + 액션 +
+ {config.domainName} + + {config.displayName} + + + {config.httpMethod} + + + {config.webclientBean} + + {config.externalPath} + + + {config.generated ? '생성 완료' : '미생성'} + + + {config.createdAt + ? new Date(config.createdAt).toLocaleDateString('ko-KR') + : '-'} + +
+ + + +
+
+
+
)} {/* 등록/수정 모달 */} diff --git a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java index 8cb1a22..15b3971 100644 --- a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.servers.Server; +import org.springdoc.core.models.GroupedOpenApi; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -33,6 +34,31 @@ public class SwaggerConfig { @Value("${server.servlet.context-path:}") private String contextPath; + @Bean + public GroupedOpenApi batchManagementApi() { + return GroupedOpenApi.builder() + .group("1. Batch Management") + .pathsToMatch("/api/batch/**") + .build(); + } + + @Bean + public GroupedOpenApi bypassConfigApi() { + return GroupedOpenApi.builder() + .group("2. Bypass Config") + .pathsToMatch("/api/bypass-config/**") + .build(); + } + + @Bean + public GroupedOpenApi bypassApi() { + return GroupedOpenApi.builder() + .group("3. Bypass API") + .pathsToMatch("/api/**") + .pathsToExclude("/api/batch/**", "/api/bypass-config/**") + .build(); + } + @Bean public OpenAPI openAPI() { return new OpenAPI() diff --git a/src/main/java/com/snp/batch/jobs/web/risk/controller/RiskController.java b/src/main/java/com/snp/batch/jobs/web/risk/controller/RiskController.java index 9f4397a..2e96d20 100644 --- a/src/main/java/com/snp/batch/jobs/web/risk/controller/RiskController.java +++ b/src/main/java/com/snp/batch/jobs/web/risk/controller/RiskController.java @@ -23,7 +23,7 @@ import java.util.List; @RestController @RequestMapping("/api/risk") @RequiredArgsConstructor -@Tag(name = "Risk", description = "선박 Risk 상세 정보 bypass API") +@Tag(name = "Risk", description = "[Service API] 선박 Risk 상세 정보 bypass API") public class RiskController extends BaseBypassController { private final RiskBypassService riskBypassService; diff --git a/src/main/java/com/snp/batch/service/BypassCodeGenerator.java b/src/main/java/com/snp/batch/service/BypassCodeGenerator.java index 1088079..5e7be1b 100644 --- a/src/main/java/com/snp/batch/service/BypassCodeGenerator.java +++ b/src/main/java/com/snp/batch/service/BypassCodeGenerator.java @@ -232,7 +232,7 @@ public class BypassCodeGenerator { @RestController @RequestMapping("{{REQUEST_MAPPING_PATH}}") @RequiredArgsConstructor - @Tag(name = "{{DOMAIN_CAP}}", description = "{{DISPLAY_NAME}} bypass API") + @Tag(name = "{{DOMAIN_CAP}}", description = "{{TAG_PREFIX}} {{DISPLAY_NAME}} bypass API") public class {{DOMAIN_CAP}}Controller extends BaseBypassController { private final {{SERVICE_CLASS}} {{SERVICE_FIELD}}; @@ -255,6 +255,7 @@ public class BypassCodeGenerator { .replace("{{REQUEST_PARAM_IMPORT}}", requestParamImport) .replace("{{REQUEST_BODY_IMPORT}}", requestBodyImport) .replace("{{MAPPING_IMPORT}}", mappingImport) + .replace("{{TAG_PREFIX}}", getTagPrefix(config.getWebclientBean())) .replace("{{DISPLAY_NAME}}", config.getDisplayName()) .replace("{{DOMAIN_CAP}}", domainCap) .replace("{{REQUEST_MAPPING_PATH}}", requestMappingPath) @@ -331,14 +332,15 @@ public class BypassCodeGenerator { String description = p.getDescription() != null ? p.getDescription() : p.getParamName(); String javaType = toJavaType(p.getParamType()); String paramName = p.getParamName(); + String example = getDefaultExample(p.getParamType()); return switch (p.getParamIn().toUpperCase()) { - case "PATH" -> "@Parameter(description = \"" + description + "\")\n" + case "PATH" -> "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n" + " @PathVariable " + javaType + " " + paramName; - case "BODY" -> "@Parameter(description = \"" + description + "\")\n" + case "BODY" -> "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n" + " @RequestBody " + javaType + " " + paramName; default -> { String required = Boolean.TRUE.equals(p.getRequired()) ? "true" : "false"; - yield "@Parameter(description = \"" + description + "\")\n" + yield "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n" + " @RequestParam(required = " + required + ") " + javaType + " " + paramName; } }; @@ -401,6 +403,35 @@ public class BypassCodeGenerator { return filePath; } + /** + * webclientBean 이름 → Swagger @Tag description 접두사 변환 + */ + private String getTagPrefix(String webclientBean) { + if (webclientBean == null) { + return "[Ship API]"; + } + return switch (webclientBean) { + case "maritimeAisApiWebClient" -> "[AIS API]"; + case "maritimeServiceApiWebClient" -> "[Service API]"; + default -> "[Ship API]"; + }; + } + + /** + * paramType → Swagger @Parameter example 기본값 결정 + */ + private String getDefaultExample(String paramType) { + if (paramType == null) { + return "9876543"; + } + return switch (paramType.toUpperCase()) { + case "INTEGER" -> "0"; + case "LONG" -> "0"; + case "BOOLEAN" -> "true"; + default -> "9876543"; + }; + } + private String capitalize(String s) { if (s == null || s.isEmpty()) { return s;