release: 2026-03-31 (40건 커밋) #118

병합
HYOJIN develop 에서 main 로 40 commits 를 머지했습니다 2026-03-31 11:09:31 +09:00
10개의 변경된 파일381개의 추가작업 그리고 143개의 파일을 삭제
Showing only changes of commit ce27c60985 - Show all commits

파일 보기

@ -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;
}

파일 보기

@ -463,19 +463,29 @@ export default function BypassConfig() {
<p className="text-sm text-wing-text">{generationResult.message}</p>
<div className="bg-wing-card rounded-lg p-3 space-y-2">
<p className="text-xs font-semibold text-wing-text mb-1"> </p>
{[
{ label: 'Controller', path: generationResult.controllerPath },
{ label: 'Service', path: generationResult.servicePath },
{ label: 'DTO', path: generationResult.dtoPath },
].map(({ label, path }) => (
<div key={label} className="flex gap-2 text-xs">
<span className="w-20 font-medium text-wing-accent shrink-0">{label}</span>
<div className="flex gap-2 text-xs">
<span className="w-20 font-medium text-wing-accent shrink-0">Controller</span>
<span className="font-mono text-wing-muted break-all">{generationResult.controllerPath}</span>
</div>
{generationResult.servicePaths.map((path, idx) => (
<div key={`service-${idx}`} className="flex gap-2 text-xs">
<span className="w-20 font-medium text-wing-accent shrink-0">
Service {generationResult.servicePaths.length > 1 ? idx + 1 : ''}
</span>
<span className="font-mono text-wing-muted break-all">{path}</span>
</div>
))}
{generationResult.dtoPaths.map((path, idx) => (
<div key={`dto-${idx}`} className="flex gap-2 text-xs">
<span className="w-20 font-medium text-wing-accent shrink-0">
DTO {generationResult.dtoPaths.length > 1 ? idx + 1 : ''}
</span>
<span className="font-mono text-wing-muted break-all">{path}</span>
</div>
))}
</div>
<div className="flex items-start gap-2 bg-amber-50 text-amber-700 rounded-lg p-3 text-xs">
<span className="shrink-0"></span>
<span className="shrink-0">&#9888;</span>
<span> API가 .</span>
</div>
</div>

파일 보기

@ -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<ApiResponse<CodeGenerationResult>> 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<BypassApiConfig> 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) {

파일 보기

@ -22,6 +22,9 @@ public class BypassConfigResponse {
/** 도메인명 (패키지명/URL 경로) */
private String domainName;
/** 엔드포인트명 (externalPath 마지막 세그먼트) */
private String endpointName;
/** 표시명 */
private String displayName;

파일 보기

@ -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<String> servicePaths;
/** 생성된 DTO 파일 경로 */
private String dtoPath;
/** 생성된 DTO 파일 경로 목록 (엔드포인트별) */
private List<String> dtoPaths;
/** 결과 메시지 */
private String message;

파일 보기

@ -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);
}
}
}

파일 보기

@ -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<BypassApiConfig, Long> {
/**
* 도메인명으로 BYPASS API 설정 조회
* 도메인명으로 BYPASS API 설정 단건 조회 (하위 호환)
*/
Optional<BypassApiConfig> findByDomainName(String domainName);
/**
* 도메인명 존재 여부 확인
* 도메인명으로 BYPASS API 설정 목록 조회 (ID )
*/
boolean existsByDomainName(String domainName);
List<BypassApiConfig> findByDomainNameOrderById(String domainName);
/**
* 도메인명 + 엔드포인트명 복합 유니크 존재 여부 확인
*/
boolean existsByDomainNameAndEndpointName(String domainName, String endpointName);
}

파일 보기

@ -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;
}

파일 보기

@ -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<BypassApiParam> params,
List<BypassApiField> fields,
boolean force) {
public CodeGenerationResult generate(List<BypassApiConfig> 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<String> dtoPaths = new ArrayList<>();
List<String> 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<BypassApiField> fields) {
private String generateDtoCode(String domain, String endpointName, List<BypassApiField> 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<BypassApiParam> 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<BypassApiParam> params) {
private String generateControllerCode(String domain, List<BypassApiConfig> 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<String> 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<ApiResponse<{{RESPONSE_GENERIC}}>> 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<ApiResponse<").append(responseGeneric).append(">> ")
.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<BypassApiParam> params, String externalPath) {
// externalPath에서 마지막 세그먼트 추출
String endpointSegment = "";
if (externalPath != null && !externalPath.isEmpty()) {
String[] segments = externalPath.split("/");
@ -364,7 +411,6 @@ public class BypassCodeGenerator {
}
}
// PATH 파라미터 추가
List<BypassApiParam> pathParams = params.stream()
.filter(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))

파일 보기

@ -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())