diff --git a/frontend/src/api/bypassApi.ts b/frontend/src/api/bypassApi.ts index 29241e1..deec912 100644 --- a/frontend/src/api/bypassApi.ts +++ b/frontend/src/api/bypassApi.ts @@ -41,6 +41,7 @@ export interface BypassConfigRequest { export interface BypassConfigResponse { id: number; domainName: string; + endpointName: string; displayName: string; webclientBean: string; externalPath: string; @@ -57,8 +58,8 @@ export interface BypassConfigResponse { export interface CodeGenerationResult { controllerPath: string; - servicePath: string; - dtoPath: string; + servicePaths: string[]; + dtoPaths: string[]; message: string; } diff --git a/frontend/src/pages/BypassConfig.tsx b/frontend/src/pages/BypassConfig.tsx index aa211a4..ae9186a 100644 --- a/frontend/src/pages/BypassConfig.tsx +++ b/frontend/src/pages/BypassConfig.tsx @@ -463,19 +463,29 @@ export default function BypassConfig() {

{generationResult.message}

생성된 파일

- {[ - { label: 'Controller', path: generationResult.controllerPath }, - { label: 'Service', path: generationResult.servicePath }, - { label: 'DTO', path: generationResult.dtoPath }, - ].map(({ label, path }) => ( -
- {label} +
+ Controller + {generationResult.controllerPath} +
+ {generationResult.servicePaths.map((path, idx) => ( +
+ + Service {generationResult.servicePaths.length > 1 ? idx + 1 : ''} + + {path} +
+ ))} + {generationResult.dtoPaths.map((path, idx) => ( +
+ + DTO {generationResult.dtoPaths.length > 1 ? idx + 1 : ''} + {path}
))}
- + 서버를 재시작하면 새 API가 활성화됩니다.
diff --git a/src/main/java/com/snp/batch/global/controller/BypassConfigController.java b/src/main/java/com/snp/batch/global/controller/BypassConfigController.java index 9ff79aa..bea3101 100644 --- a/src/main/java/com/snp/batch/global/controller/BypassConfigController.java +++ b/src/main/java/com/snp/batch/global/controller/BypassConfigController.java @@ -79,7 +79,7 @@ public class BypassConfigController { @Operation( summary = "코드 생성", - description = "등록된 설정을 기반으로 Controller, Service, DTO Java 소스 코드를 생성합니다." + description = "등록된 설정의 도메인 전체를 기반으로 Controller, Service, DTO Java 소스 코드를 생성합니다. 같은 도메인의 모든 설정을 하나의 Controller로 합칩니다." ) @PostMapping("/{id}/generate") public ResponseEntity> generateCode( @@ -89,10 +89,13 @@ public class BypassConfigController { BypassApiConfig config = configRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id)); - CodeGenerationResult result = bypassCodeGenerator.generate( - config, config.getParams(), config.getFields(), force); + // 같은 도메인의 모든 설정을 조회하여 함께 생성 + List domainConfigs = configRepository.findByDomainNameOrderById(config.getDomainName()); - bypassConfigService.markAsGenerated(id); + CodeGenerationResult result = bypassCodeGenerator.generate(domainConfigs, force); + + // 같은 도메인의 모든 설정을 generated로 마킹 + domainConfigs.forEach(c -> bypassConfigService.markAsGenerated(c.getId())); return ResponseEntity.ok(ApiResponse.success(result)); } catch (IllegalStateException e) { diff --git a/src/main/java/com/snp/batch/global/dto/BypassConfigResponse.java b/src/main/java/com/snp/batch/global/dto/BypassConfigResponse.java index 2779d75..886af93 100644 --- a/src/main/java/com/snp/batch/global/dto/BypassConfigResponse.java +++ b/src/main/java/com/snp/batch/global/dto/BypassConfigResponse.java @@ -22,6 +22,9 @@ public class BypassConfigResponse { /** 도메인명 (패키지명/URL 경로) */ private String domainName; + /** 엔드포인트명 (externalPath 마지막 세그먼트) */ + private String endpointName; + /** 표시명 */ private String displayName; diff --git a/src/main/java/com/snp/batch/global/dto/CodeGenerationResult.java b/src/main/java/com/snp/batch/global/dto/CodeGenerationResult.java index 2c00937..d1ed597 100644 --- a/src/main/java/com/snp/batch/global/dto/CodeGenerationResult.java +++ b/src/main/java/com/snp/batch/global/dto/CodeGenerationResult.java @@ -5,8 +5,11 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; + /** * 코드 자동 생성 결과 DTO + * 같은 도메인에 N개의 엔드포인트를 지원하므로 Service/DTO는 목록으로 반환 */ @Getter @Builder @@ -17,11 +20,11 @@ public class CodeGenerationResult { /** 생성된 Controller 파일 경로 */ private String controllerPath; - /** 생성된 Service 파일 경로 */ - private String servicePath; + /** 생성된 Service 파일 경로 목록 (엔드포인트별) */ + private List servicePaths; - /** 생성된 DTO 파일 경로 */ - private String dtoPath; + /** 생성된 DTO 파일 경로 목록 (엔드포인트별) */ + private List dtoPaths; /** 결과 메시지 */ private String message; diff --git a/src/main/java/com/snp/batch/global/model/BypassApiConfig.java b/src/main/java/com/snp/batch/global/model/BypassApiConfig.java index b1b209b..edd2c54 100644 --- a/src/main/java/com/snp/batch/global/model/BypassApiConfig.java +++ b/src/main/java/com/snp/batch/global/model/BypassApiConfig.java @@ -14,7 +14,9 @@ import java.util.List; * JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정 */ @Entity -@Table(name = "bypass_api_config") +@Table(name = "bypass_api_config", uniqueConstraints = { + @UniqueConstraint(columnNames = {"domain_name", "endpoint_name"}) +}) @Getter @Setter @Builder @@ -30,9 +32,16 @@ public class BypassApiConfig { * 도메인명 (패키지명/URL 경로) * 예: "ship-info", "port-data" */ - @Column(name = "domain_name", unique = true, nullable = false, length = 50) + @Column(name = "domain_name", nullable = false, length = 50) private String domainName; + /** + * 엔드포인트명 (externalPath의 마지막 세그먼트에서 자동 추출) + * 예: "CompliancesByImos", "CompanyCompliancesByImos" + */ + @Column(name = "endpoint_name", nullable = false, length = 100) + private String endpointName; + /** * 표시명 * 예: "선박 정보 API", "항만 데이터 API" @@ -117,12 +126,28 @@ public class BypassApiConfig { /** * 엔티티 저장 전 자동 호출 (INSERT 시) + * endpointName이 null이면 externalPath에서 자동 추출 (마이그레이션 대응) */ @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); this.createdAt = now; this.updatedAt = now; + if (this.endpointName == null || this.endpointName.isEmpty()) { + this.endpointName = extractEndpointName(this.externalPath); + } + } + + /** + * 엔티티 업데이트 전 자동 호출 (UPDATE 시) + * endpointName이 null이면 externalPath에서 자동 추출 (마이그레이션 대응) + */ + private static String extractEndpointName(String externalPath) { + if (externalPath == null || externalPath.isEmpty()) { + return ""; + } + String[] segments = externalPath.split("/"); + return segments[segments.length - 1]; } /** @@ -131,5 +156,8 @@ public class BypassApiConfig { @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); + if (this.endpointName == null || this.endpointName.isEmpty()) { + this.endpointName = extractEndpointName(this.externalPath); + } } } diff --git a/src/main/java/com/snp/batch/global/repository/BypassApiConfigRepository.java b/src/main/java/com/snp/batch/global/repository/BypassApiConfigRepository.java index 89a2322..1f19563 100644 --- a/src/main/java/com/snp/batch/global/repository/BypassApiConfigRepository.java +++ b/src/main/java/com/snp/batch/global/repository/BypassApiConfigRepository.java @@ -4,6 +4,7 @@ import com.snp.batch.global.model.BypassApiConfig; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; /** @@ -14,12 +15,17 @@ import java.util.Optional; public interface BypassApiConfigRepository extends JpaRepository { /** - * 도메인명으로 BYPASS API 설정 조회 + * 도메인명으로 BYPASS API 설정 단건 조회 (하위 호환) */ Optional findByDomainName(String domainName); /** - * 도메인명 존재 여부 확인 + * 도메인명으로 BYPASS API 설정 목록 조회 (ID 순) */ - boolean existsByDomainName(String domainName); + List findByDomainNameOrderById(String domainName); + + /** + * 도메인명 + 엔드포인트명 복합 유니크 존재 여부 확인 + */ + boolean existsByDomainNameAndEndpointName(String domainName, String endpointName); } diff --git a/src/main/java/com/snp/batch/jobs/web/compliance/dto/ComplianceBypassDto.java b/src/main/java/com/snp/batch/jobs/web/compliance/dto/ComplianceBypassDto.java new file mode 100644 index 0000000..600d35d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/web/compliance/dto/ComplianceBypassDto.java @@ -0,0 +1,122 @@ +package com.snp.batch.jobs.web.compliance.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ComplianceBypassDto { + + @JsonProperty("dateAmended") + private String dateAmended; + + @JsonProperty("legalOverall") + private Integer legalOverall; + + @JsonProperty("lrimoShipNo") + private String lrimoShipNo; + + @JsonProperty("shipBESSanctionList") + private Integer shipBESSanctionList; + + @JsonProperty("shipDarkActivityIndicator") + private Integer shipDarkActivityIndicator; + + @JsonProperty("shipDetailsNoLongerMaintained") + private Integer shipDetailsNoLongerMaintained; + + @JsonProperty("shipEUSanctionList") + private Integer shipEUSanctionList; + + @JsonProperty("shipFlagDisputed") + private Integer shipFlagDisputed; + + @JsonProperty("shipFlagSanctionedCountry") + private Integer shipFlagSanctionedCountry; + + @JsonProperty("shipHistoricalFlagSanctionedCountry") + private Integer shipHistoricalFlagSanctionedCountry; + + @JsonProperty("shipOFACAdvisoryList") + private Integer shipOFACAdvisoryList; + + @JsonProperty("shipOFACNonSDNSanctionList") + private Integer shipOFACNonSDNSanctionList; + + @JsonProperty("shipOFACSanctionList") + private Integer shipOFACSanctionList; + + @JsonProperty("shipOwnerAustralianSanctionList") + private Integer shipOwnerAustralianSanctionList; + + @JsonProperty("shipOwnerBESSanctionList") + private Integer shipOwnerBESSanctionList; + + @JsonProperty("shipOwnerCanadianSanctionList") + private Integer shipOwnerCanadianSanctionList; + + @JsonProperty("shipOwnerEUSanctionList") + private Integer shipOwnerEUSanctionList; + + @JsonProperty("shipOwnerFATFJurisdiction") + private Integer shipOwnerFATFJurisdiction; + + @JsonProperty("shipOwnerHistoricalOFACSanctionedCountry") + private Integer shipOwnerHistoricalOFACSanctionedCountry; + + @JsonProperty("shipOwnerOFACSSIList") + private Integer shipOwnerOFACSSIList; + + @JsonProperty("shipOwnerOFACSanctionList") + private Integer shipOwnerOFACSanctionList; + + @JsonProperty("shipOwnerOFACSanctionedCountry") + private Integer shipOwnerOFACSanctionedCountry; + + @JsonProperty("shipOwnerParentCompanyNonCompliance") + private Integer shipOwnerParentCompanyNonCompliance; + + @JsonProperty("shipOwnerParentFATFJurisdiction") + private String shipOwnerParentFATFJurisdiction; + + @JsonProperty("shipOwnerParentOFACSanctionedCountry") + private String shipOwnerParentOFACSanctionedCountry; + + @JsonProperty("shipOwnerSwissSanctionList") + private Integer shipOwnerSwissSanctionList; + + @JsonProperty("shipOwnerUAESanctionList") + private Integer shipOwnerUAESanctionList; + + @JsonProperty("shipOwnerUNSanctionList") + private Integer shipOwnerUNSanctionList; + + @JsonProperty("shipSTSPartnerNonComplianceLast12m") + private Integer shipSTSPartnerNonComplianceLast12m; + + @JsonProperty("shipSanctionedCountryPortCallLast12m") + private Integer shipSanctionedCountryPortCallLast12m; + + @JsonProperty("shipSanctionedCountryPortCallLast3m") + private Integer shipSanctionedCountryPortCallLast3m; + + @JsonProperty("shipSanctionedCountryPortCallLast6m") + private Integer shipSanctionedCountryPortCallLast6m; + + @JsonProperty("shipSecurityLegalDisputeEvent") + private Integer shipSecurityLegalDisputeEvent; + + @JsonProperty("shipSwissSanctionList") + private Integer shipSwissSanctionList; + + @JsonProperty("shipUNSanctionList") + private Integer shipUNSanctionList; + +} diff --git a/src/main/java/com/snp/batch/service/BypassCodeGenerator.java b/src/main/java/com/snp/batch/service/BypassCodeGenerator.java index 5e7be1b..0b4049d 100644 --- a/src/main/java/com/snp/batch/service/BypassCodeGenerator.java +++ b/src/main/java/com/snp/batch/service/BypassCodeGenerator.java @@ -11,12 +11,15 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; /** * BYPASS API Java 소스 코드를 자동 생성하는 서비스. - * RiskBypassService / RiskController 패턴을 기반으로 DTO, Service, Controller를 생성합니다. + * 같은 도메인의 N개 설정을 받아 N개의 DTO + Service와 1개의 Controller를 생성합니다. */ @Slf4j @Service @@ -25,47 +28,59 @@ public class BypassCodeGenerator { private static final String BASE_PACKAGE = "com.snp.batch.jobs.web"; /** - * BYPASS API 코드를 생성합니다. + * 같은 도메인의 BYPASS API 코드를 생성합니다. + * 엔드포인트별 DTO/Service를 각각 생성하고, Controller 1개에 모든 엔드포인트 메서드를 합칩니다. * - * @param config 설정 정보 - * @param params 파라미터 목록 - * @param fields DTO 필드 목록 - * @param force 기존 파일 덮어쓰기 여부 + * @param configs 같은 domainName을 가진 설정 목록 + * @param force 기존 파일 덮어쓰기 여부 * @return 생성 결과 */ - public CodeGenerationResult generate(BypassApiConfig config, - List params, - List fields, - boolean force) { + public CodeGenerationResult generate(List configs, boolean force) { + if (configs == null || configs.isEmpty()) { + throw new IllegalArgumentException("생성할 설정이 없습니다."); + } + 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 domain = configs.get(0).getDomainName(); 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); + List dtoPaths = new ArrayList<>(); + List servicePaths = new ArrayList<>(); + + for (BypassApiConfig config : configs) { + String endpointName = config.getEndpointName(); + + String dtoCode = generateDtoCode(domain, endpointName, config.getFields()); + String serviceCode = generateServiceCode(domain, endpointName, config, config.getParams()); + + Path dtoFilePath = writeFile(basePath + "/dto/" + endpointName + "Dto.java", dtoCode, force); + Path serviceFilePath = writeFile(basePath + "/service/" + endpointName + "Service.java", serviceCode, force); + + dtoPaths.add(dtoFilePath.toString()); + servicePaths.add(serviceFilePath.toString()); + } + + String controllerCode = generateControllerCode(domain, configs); + String domainCapitalized = capitalize(domain); + Path controllerFilePath = writeFile( + basePath + "/controller/" + domainCapitalized + "Controller.java", controllerCode, force); + + log.info("코드 생성 완료 - domain: {}, endpoints: {}, controller: {}", + domain, configs.stream().map(BypassApiConfig::getEndpointName).toList(), controllerFilePath); return CodeGenerationResult.builder() - .dtoPath(dtoPath.toString()) - .servicePath(servicePath.toString()) - .controllerPath(controllerPath.toString()) + .controllerPath(controllerFilePath.toString()) + .servicePaths(servicePaths) + .dtoPaths(dtoPaths) .message("코드 생성 완료. 서버를 재시작하면 새 API가 활성화됩니다.") .build(); } /** * DTO 코드 생성. - * 각 field에 대해 @JsonProperty + private 필드를 생성합니다. + * endpointName 기반 클래스명: {EndpointName}Dto */ - private String generateDtoCode(String domain, String domainCap, List fields) { + private String generateDtoCode(String domain, String endpointName, List fields) { String packageName = BASE_PACKAGE + "." + domain + ".dto"; boolean needsLocalDateTime = fields.stream() .anyMatch(f -> "LocalDateTime".equals(f.getFieldType())); @@ -103,24 +118,26 @@ public class BypassCodeGenerator { """ .replace("{{PACKAGE}}", packageName) .replace("{{IMPORTS}}", imports.toString()) - .replace("{{CLASS_NAME}}", domainCap + "BypassDto") + .replace("{{CLASS_NAME}}", endpointName + "Dto") .replace("{{FIELDS}}", fieldLines.toString()); } /** * Service 코드 생성. - * BaseBypassService를 상속하여 GET/POST, LIST/SINGLE 조합에 맞는 fetch 메서드를 생성합니다. + * endpointName 기반 클래스명: {EndpointName}Service + * BaseBypassService<{EndpointName}Dto>를 상속합니다. */ - private String generateServiceCode(String domain, String domainCap, + private String generateServiceCode(String domain, String endpointName, BypassApiConfig config, List params) { String packageName = BASE_PACKAGE + "." + domain + ".service"; String dtoPackage = BASE_PACKAGE + "." + domain + ".dto"; - String dtoClass = domainCap + "BypassDto"; + String dtoClass = endpointName + "Dto"; + String serviceClass = endpointName + "Service"; boolean isList = "LIST".equalsIgnoreCase(config.getResponseType()); boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod()); String returnType = isList ? "List<" + dtoClass + ">" : dtoClass; - String methodName = "get" + domainCap + "Data"; + String methodName = "get" + endpointName + "Data"; String fetchMethod = buildFetchMethodCall(config, params, isList, isPost); String methodParams = buildMethodParams(params); @@ -141,9 +158,9 @@ public class BypassCodeGenerator { * 외부 Maritime API에서 데이터를 실시간 조회하여 그대로 반환 */ @Service - public class {{DOMAIN_CAP}}BypassService extends BaseBypassService<{{DTO_CLASS}}> { + public class {{SERVICE_CLASS}} extends BaseBypassService<{{DTO_CLASS}}> { - public {{DOMAIN_CAP}}BypassService( + public {{SERVICE_CLASS}}( @Qualifier("{{WEBCLIENT_BEAN}}") WebClient webClient) { super(webClient, "{{EXTERNAL_PATH}}", "{{DISPLAY_NAME}}", new ParameterizedTypeReference<>() {}, @@ -164,7 +181,7 @@ public class BypassCodeGenerator { .replace("{{DTO_IMPORT}}", dtoPackage + "." + dtoClass) .replace("{{LIST_IMPORT}}", listImport) .replace("{{DISPLAY_NAME}}", config.getDisplayName()) - .replace("{{DOMAIN_CAP}}", domainCap) + .replace("{{SERVICE_CLASS}}", serviceClass) .replace("{{DTO_CLASS}}", dtoClass) .replace("{{WEBCLIENT_BEAN}}", config.getWebclientBean()) .replace("{{EXTERNAL_PATH}}", config.getExternalPath()) @@ -176,95 +193,126 @@ public class BypassCodeGenerator { /** * Controller 코드 생성. - * BaseBypassController를 상속하여 GET/POST 엔드포인트를 생성합니다. + * 같은 도메인의 모든 설정을 합쳐 하나의 Controller에 N개의 엔드포인트 메서드를 생성합니다. */ - private String generateControllerCode(String domain, String domainCap, - BypassApiConfig config, List params) { + private String generateControllerCode(String domain, List configs) { 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<" + 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 mappingImport = isPost - ? "import org.springframework.web.bind.annotation.PostMapping;\n" - : "import org.springframework.web.bind.annotation.GetMapping;\n"; - - String mappingPath = buildMappingPath(params, config.getExternalPath()); + String dtoPackage = BASE_PACKAGE + "." + domain + ".dto"; + String domainCap = capitalize(domain); String requestMappingPath = "/api/" + domain; - return """ - package {{PACKAGE}}; + // imports 합산 (중복 제거) + Set importSet = new LinkedHashSet<>(); + 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;"); + importSet.add("import io.swagger.v3.oas.annotations.tags.Tag;"); + importSet.add("import lombok.RequiredArgsConstructor;"); + importSet.add("import org.springframework.http.ResponseEntity;"); + importSet.add("import org.springframework.web.bind.annotation.RequestMapping;"); + importSet.add("import org.springframework.web.bind.annotation.RestController;"); - import {{DTO_IMPORT}}; - import {{SERVICE_IMPORT}}; - 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; - {{LIST_IMPORT}}import lombok.RequiredArgsConstructor; - import org.springframework.http.ResponseEntity; - import org.springframework.web.bind.annotation.RequestMapping; - import org.springframework.web.bind.annotation.RestController; - {{MAPPING_IMPORT}}{{PATH_VARIABLE_IMPORT}}{{REQUEST_PARAM_IMPORT}}{{REQUEST_BODY_IMPORT}} - /** - * {{DISPLAY_NAME}} bypass API - * S&P Maritime API에서 데이터를 실시간 조회하여 그대로 반환 - */ - @RestController - @RequestMapping("{{REQUEST_MAPPING_PATH}}") - @RequiredArgsConstructor - @Tag(name = "{{DOMAIN_CAP}}", description = "{{TAG_PREFIX}} {{DISPLAY_NAME}} bypass API") - public class {{DOMAIN_CAP}}Controller extends BaseBypassController { + boolean anyList = configs.stream().anyMatch(c -> "LIST".equalsIgnoreCase(c.getResponseType())); + boolean anyPost = configs.stream().anyMatch(c -> "POST".equalsIgnoreCase(c.getHttpMethod())); + boolean anyGet = configs.stream().anyMatch(c -> !"POST".equalsIgnoreCase(c.getHttpMethod())); + boolean anyPath = configs.stream() + .anyMatch(c -> c.getParams().stream().anyMatch(p -> "PATH".equalsIgnoreCase(p.getParamIn()))); + boolean anyQuery = configs.stream() + .anyMatch(c -> c.getParams().stream().anyMatch(p -> "QUERY".equalsIgnoreCase(p.getParamIn()))); + boolean anyBody = configs.stream() + .anyMatch(c -> c.getParams().stream().anyMatch(p -> "BODY".equalsIgnoreCase(p.getParamIn()))); - private final {{SERVICE_CLASS}} {{SERVICE_FIELD}}; + if (anyList) { + importSet.add("import java.util.List;"); + } + if (anyPost) { + importSet.add("import org.springframework.web.bind.annotation.PostMapping;"); + } + if (anyGet) { + importSet.add("import org.springframework.web.bind.annotation.GetMapping;"); + } + if (anyPath) { + importSet.add("import org.springframework.web.bind.annotation.PathVariable;"); + } + if (anyQuery) { + importSet.add("import org.springframework.web.bind.annotation.RequestParam;"); + } + if (anyBody) { + importSet.add("import org.springframework.web.bind.annotation.RequestBody;"); + } - @Operation( - summary = "{{DISPLAY_NAME}} 조회", - description = "S&P API에서 {{DISPLAY_NAME}} 데이터를 요청하고 응답을 그대로 반환합니다." - ) - {{MAPPING_ANNOTATION}} - public ResponseEntity> get{{DOMAIN_CAP}}Data({{PARAM_ANNOTATIONS}}) { - return execute(() -> {{SERVICE_FIELD}}.get{{DOMAIN_CAP}}Data({{SERVICE_CALL_ARGS}})); - } - } - """ - .replace("{{PACKAGE}}", packageName) - .replace("{{DTO_IMPORT}}", dtoPackage + "." + dtoClass) - .replace("{{SERVICE_IMPORT}}", servicePackage + "." + serviceClass) - .replace("{{LIST_IMPORT}}", listImport) - .replace("{{PATH_VARIABLE_IMPORT}}", pathVariableImport) - .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) - .replace("{{SERVICE_CLASS}}", serviceClass) - .replace("{{SERVICE_FIELD}}", serviceField) - .replace("{{MAPPING_ANNOTATION}}", mappingAnnotation + mappingPath) - .replace("{{RESPONSE_GENERIC}}", responseGeneric) - .replace("{{PARAM_ANNOTATIONS}}", paramAnnotations.isEmpty() ? "" : paramAnnotations) - .replace("{{SERVICE_CALL_ARGS}}", serviceCallArgs); + for (BypassApiConfig config : configs) { + String endpointName = config.getEndpointName(); + importSet.add("import " + dtoPackage + "." + endpointName + "Dto;"); + importSet.add("import " + servicePackage + "." + endpointName + "Service;"); + } + + String importsStr = importSet.stream().collect(Collectors.joining("\n")); + + // 필드 선언부 + StringBuilder fields = new StringBuilder(); + for (BypassApiConfig config : configs) { + String endpointName = config.getEndpointName(); + String serviceClass = endpointName + "Service"; + String serviceField = Character.toLowerCase(serviceClass.charAt(0)) + serviceClass.substring(1); + fields.append(" private final ").append(serviceClass).append(" ").append(serviceField).append(";\n"); + } + + // 태그 description은 첫 번째 config의 webclientBean 기준 + String tagPrefix = getTagPrefix(configs.get(0).getWebclientBean()); + String tagDescription = tagPrefix + " " + domainCap + " bypass API"; + + // 엔드포인트 메서드 목록 + StringBuilder methods = new StringBuilder(); + for (BypassApiConfig config : configs) { + String endpointName = config.getEndpointName(); + String dtoClass = endpointName + "Dto"; + String serviceClass = endpointName + "Service"; + 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<" + dtoClass + ">" : dtoClass; + String mappingAnnotation = isPost ? "@PostMapping" : "@GetMapping"; + String mappingPath = buildMappingPath(config.getParams(), config.getExternalPath()); + String paramAnnotations = buildControllerParamAnnotations(config.getParams()); + String serviceCallArgs = buildServiceCallArgs(config.getParams()); + String methodName = "get" + endpointName + "Data"; + + methods.append("\n"); + methods.append(" @Operation(\n"); + methods.append(" summary = \"").append(config.getDisplayName()).append(" 조회\",\n"); + methods.append(" description = \"S&P API에서 ").append(config.getDisplayName()) + .append(" 데이터를 요청하고 응답을 그대로 반환합니다.\"\n"); + methods.append(" )\n"); + methods.append(" ").append(mappingAnnotation).append(mappingPath).append("\n"); + methods.append(" public ResponseEntity> ") + .append(methodName).append("("); + if (!paramAnnotations.isEmpty()) { + methods.append(paramAnnotations); + } + methods.append(") {\n"); + methods.append(" return execute(() -> ").append(serviceField) + .append(".").append(methodName).append("(").append(serviceCallArgs).append("));\n"); + methods.append(" }\n"); + } + + return "package " + packageName + ";\n\n" + + importsStr + "\n\n" + + "/**\n" + + " * " + domainCap + " bypass API\n" + + " * S&P Maritime API에서 데이터를 실시간 조회하여 그대로 반환\n" + + " */\n" + + "@RestController\n" + + "@RequestMapping(\"" + requestMappingPath + "\")\n" + + "@RequiredArgsConstructor\n" + + "@Tag(name = \"" + domainCap + "\", description = \"" + tagDescription + "\")\n" + + "public class " + domainCap + "Controller extends BaseBypassController {\n\n" + + fields + + methods + + "}\n"; } /** @@ -355,7 +403,6 @@ public class BypassCodeGenerator { * PATH 파라미터 imo 추가 시 → ("/CompliancesByImos/{imo}") */ private String buildMappingPath(List params, String externalPath) { - // externalPath에서 마지막 세그먼트 추출 String endpointSegment = ""; if (externalPath != null && !externalPath.isEmpty()) { String[] segments = externalPath.split("/"); @@ -364,7 +411,6 @@ public class BypassCodeGenerator { } } - // PATH 파라미터 추가 List pathParams = params.stream() .filter(p -> "PATH".equalsIgnoreCase(p.getParamIn())) .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) diff --git a/src/main/java/com/snp/batch/service/BypassConfigService.java b/src/main/java/com/snp/batch/service/BypassConfigService.java index b2bd54a..ca42345 100644 --- a/src/main/java/com/snp/batch/service/BypassConfigService.java +++ b/src/main/java/com/snp/batch/service/BypassConfigService.java @@ -57,8 +57,10 @@ public class BypassConfigService { */ @Transactional public BypassConfigResponse createConfig(BypassConfigRequest request) { - if (configRepository.existsByDomainName(request.getDomainName())) { - throw new IllegalArgumentException("이미 존재하는 도메인입니다: " + request.getDomainName()); + String endpointName = extractEndpointName(request.getExternalPath()); + if (configRepository.existsByDomainNameAndEndpointName(request.getDomainName(), endpointName)) { + throw new IllegalArgumentException( + "이미 존재하는 도메인+엔드포인트 조합입니다: " + request.getDomainName() + "/" + endpointName); } BypassApiConfig config = toEntity(request); @@ -155,6 +157,7 @@ public class BypassConfigService { return BypassConfigResponse.builder() .id(config.getId()) .domainName(config.getDomainName()) + .endpointName(config.getEndpointName()) .displayName(config.getDisplayName()) .webclientBean(config.getWebclientBean()) .externalPath(config.getExternalPath()) @@ -173,6 +176,7 @@ public class BypassConfigService { private BypassApiConfig toEntity(BypassConfigRequest request) { return BypassApiConfig.builder() .domainName(request.getDomainName()) + .endpointName(extractEndpointName(request.getExternalPath())) .displayName(request.getDisplayName()) .webclientBean(request.getWebclientBean()) .externalPath(request.getExternalPath()) @@ -182,6 +186,18 @@ public class BypassConfigService { .build(); } + /** + * externalPath의 마지막 세그먼트를 endpointName으로 추출 + * 예: "/RiskAndCompliance/CompliancesByImos" → "CompliancesByImos" + */ + private String extractEndpointName(String externalPath) { + if (externalPath == null || externalPath.isEmpty()) { + return ""; + } + String[] segments = externalPath.split("/"); + return segments[segments.length - 1]; + } + private BypassApiParam toParamEntity(BypassParamDto dto) { return BypassApiParam.builder() .paramName(dto.getParamName())