package com.snp.batch.service; import com.snp.batch.global.dto.CodeGenerationResult; import com.snp.batch.global.model.BypassApiConfig; import com.snp.batch.global.model.BypassApiField; import com.snp.batch.global.model.BypassApiParam; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.stream.Collectors; /** * BYPASS API Java 소스 코드를 자동 생성하는 서비스. * RiskBypassService / RiskController 패턴을 기반으로 DTO, Service, Controller를 생성합니다. */ @Slf4j @Service public class BypassCodeGenerator { private static final String BASE_PACKAGE = "com.snp.batch.jobs.web"; /** * BYPASS API 코드를 생성합니다. * * @param config 설정 정보 * @param params 파라미터 목록 * @param fields DTO 필드 목록 * @param force 기존 파일 덮어쓰기 여부 * @return 생성 결과 */ public CodeGenerationResult generate(BypassApiConfig config, List params, List fields, boolean force) { 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 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); return CodeGenerationResult.builder() .dtoPath(dtoPath.toString()) .servicePath(servicePath.toString()) .controllerPath(controllerPath.toString()) .message("코드 생성 완료. 서버를 재시작하면 새 API가 활성화됩니다.") .build(); } /** * DTO 코드 생성. * 각 field에 대해 @JsonProperty + private 필드를 생성합니다. */ private String generateDtoCode(String domain, String domainCap, List fields) { String packageName = BASE_PACKAGE + "." + domain + ".dto"; boolean needsLocalDateTime = fields.stream() .anyMatch(f -> "LocalDateTime".equals(f.getFieldType())); StringBuilder imports = new StringBuilder(); imports.append("import com.fasterxml.jackson.annotation.JsonProperty;\n"); imports.append("import lombok.AllArgsConstructor;\n"); imports.append("import lombok.Builder;\n"); imports.append("import lombok.Getter;\n"); imports.append("import lombok.NoArgsConstructor;\n"); imports.append("import lombok.Setter;\n"); if (needsLocalDateTime) { imports.append("import java.time.LocalDateTime;\n"); } StringBuilder fieldLines = new StringBuilder(); for (BypassApiField field : fields) { String jsonProp = field.getJsonProperty() != null ? field.getJsonProperty() : field.getFieldName(); fieldLines.append(" @JsonProperty(\"").append(jsonProp).append("\")\n"); fieldLines.append(" private ").append(field.getFieldType()).append(" ").append(field.getFieldName()).append(";\n\n"); } return """ package {{PACKAGE}}; {{IMPORTS}} @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor public class {{CLASS_NAME}} { {{FIELDS}}} """ .replace("{{PACKAGE}}", packageName) .replace("{{IMPORTS}}", imports.toString()) .replace("{{CLASS_NAME}}", domainCap + "BypassDto") .replace("{{FIELDS}}", fieldLines.toString()); } /** * Service 코드 생성. * BaseBypassService를 상속하여 GET/POST, LIST/SINGLE 조합에 맞는 fetch 메서드를 생성합니다. */ private String generateServiceCode(String domain, String domainCap, BypassApiConfig config, List params) { String packageName = BASE_PACKAGE + "." + domain + ".service"; String dtoPackage = BASE_PACKAGE + "." + domain + ".dto"; String dtoClass = domainCap + "BypassDto"; boolean isList = "LIST".equalsIgnoreCase(config.getResponseType()); boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod()); String returnType = isList ? "List<" + dtoClass + ">" : dtoClass; String methodName = "get" + domainCap + "Data"; String fetchMethod = buildFetchMethodCall(config, params, isList, isPost); String methodParams = buildMethodParams(params); String listImport = isList ? "import java.util.List;\n" : ""; return """ package {{PACKAGE}}; import {{DTO_IMPORT}}; import com.snp.batch.common.web.service.BaseBypassService; {{LIST_IMPORT}}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; /** * {{DISPLAY_NAME}} bypass 서비스 * 외부 Maritime API에서 데이터를 실시간 조회하여 그대로 반환 */ @Service public class {{DOMAIN_CAP}}BypassService extends BaseBypassService<{{DTO_CLASS}}> { public {{DOMAIN_CAP}}BypassService( @Qualifier("{{WEBCLIENT_BEAN}}") WebClient webClient) { super(webClient, "{{EXTERNAL_PATH}}", "{{DISPLAY_NAME}}", new ParameterizedTypeReference<>() {}, new ParameterizedTypeReference<>() {}); } /** * {{DISPLAY_NAME}} 데이터를 조회합니다. * * @return {{DISPLAY_NAME}} */ public {{RETURN_TYPE}} {{METHOD_NAME}}({{METHOD_PARAMS}}) { {{FETCH_METHOD}} } } """ .replace("{{PACKAGE}}", packageName) .replace("{{DTO_IMPORT}}", dtoPackage + "." + dtoClass) .replace("{{LIST_IMPORT}}", listImport) .replace("{{DISPLAY_NAME}}", config.getDisplayName()) .replace("{{DOMAIN_CAP}}", domainCap) .replace("{{DTO_CLASS}}", dtoClass) .replace("{{WEBCLIENT_BEAN}}", config.getWebclientBean()) .replace("{{EXTERNAL_PATH}}", config.getExternalPath()) .replace("{{RETURN_TYPE}}", returnType) .replace("{{METHOD_NAME}}", methodName) .replace("{{METHOD_PARAMS}}", methodParams) .replace("{{FETCH_METHOD}}", fetchMethod); } /** * Controller 코드 생성. * BaseBypassController를 상속하여 GET/POST 엔드포인트를 생성합니다. */ private String generateControllerCode(String domain, String domainCap, BypassApiConfig config, List params) { 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 requestMappingPath = "/api/" + domain; return """ package {{PACKAGE}}; 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 { private final {{SERVICE_CLASS}} {{SERVICE_FIELD}}; @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); } /** * fetch 메서드 호출 코드 생성 */ private String buildFetchMethodCall(BypassApiConfig config, List params, boolean isList, boolean isPost) { List queryParams = params.stream() .filter(p -> "QUERY".equalsIgnoreCase(p.getParamIn())) .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .toList(); StringBuilder uriBuilder = new StringBuilder(); uriBuilder.append("uri -> uri.path(getApiPath())"); for (BypassApiParam p : queryParams) { uriBuilder.append("\n .queryParam(\"") .append(p.getParamName()).append("\", ").append(p.getParamName()).append(")"); } uriBuilder.append("\n .build()"); String fetchName; if (isPost) { fetchName = isList ? "fetchPostList" : "fetchPostOne"; BypassApiParam bodyParam = params.stream() .filter(p -> "BODY".equalsIgnoreCase(p.getParamIn())) .findFirst().orElse(null); String bodyArg = bodyParam != null ? bodyParam.getParamName() : "null"; return "return " + fetchName + "(" + bodyArg + ", " + uriBuilder + ");"; } else { fetchName = isList ? "fetchGetList" : "fetchGetOne"; return "return " + fetchName + "(" + uriBuilder + ");"; } } /** * 메서드 파라미터 목록 생성 (Java 타입 + 이름) */ private String buildMethodParams(List params) { return params.stream() .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .map(p -> toJavaType(p.getParamType()) + " " + p.getParamName()) .collect(Collectors.joining(", ")); } /** * 서비스 호출 인자 목록 생성 */ private String buildServiceCallArgs(List params) { return params.stream() .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .map(BypassApiParam::getParamName) .collect(Collectors.joining(", ")); } /** * Controller 메서드 파라미터 어노테이션 포함 목록 생성 */ private String buildControllerParamAnnotations(List params) { if (params.isEmpty()) { return ""; } return params.stream() .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .map(p -> { 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 + "\", example = \"" + example + "\")\n" + " @PathVariable " + javaType + " " + paramName; 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 + "\", example = \"" + example + "\")\n" + " @RequestParam(required = " + required + ") " + javaType + " " + paramName; } }; }) .collect(Collectors.joining(",\n ")); } /** * @GetMapping / @PostMapping에 붙는 경로 생성 * externalPath의 마지막 세그먼트를 내부 경로로 사용 + PATH 파라미터 추가 * 예: /RiskAndCompliance/CompliancesByImos → ("/CompliancesByImos") * PATH 파라미터 imo 추가 시 → ("/CompliancesByImos/{imo}") */ private String buildMappingPath(List params, String externalPath) { // externalPath에서 마지막 세그먼트 추출 String endpointSegment = ""; if (externalPath != null && !externalPath.isEmpty()) { String[] segments = externalPath.split("/"); if (segments.length > 0) { endpointSegment = "/" + segments[segments.length - 1]; } } // PATH 파라미터 추가 List pathParams = params.stream() .filter(p -> "PATH".equalsIgnoreCase(p.getParamIn())) .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .toList(); String pathSuffix = pathParams.stream() .map(p -> "{" + p.getParamName() + "}") .collect(Collectors.joining("/", pathParams.isEmpty() ? "" : "/", "")); String fullPath = endpointSegment + pathSuffix; if (fullPath.isEmpty()) { return ""; } return "(\"" + fullPath + "\")"; } /** * 파일을 지정 경로에 씁니다. * * @param path 파일 경로 * @param content 파일 내용 * @param force 덮어쓰기 허용 여부 * @return 생성된 파일 경로 */ private Path writeFile(String path, String content, boolean force) { Path filePath = Path.of(path); if (Files.exists(filePath) && !force) { throw new IllegalStateException("파일이 이미 존재합니다: " + path + " (덮어쓰려면 force 옵션을 사용하세요)"); } try { Files.createDirectories(filePath.getParent()); Files.writeString(filePath, content, StandardCharsets.UTF_8); log.info("파일 생성: {}", filePath); } catch (IOException e) { throw new RuntimeException("파일 쓰기 실패: " + path, e); } 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; } return Character.toUpperCase(s.charAt(0)) + s.substring(1); } /** * param_type → Java 타입 변환 */ private String toJavaType(String paramType) { if (paramType == null) { return "String"; } return switch (paramType.toUpperCase()) { case "INTEGER" -> "Integer"; case "LONG" -> "Long"; case "BOOLEAN" -> "Boolean"; default -> "String"; }; } }