package com.ftptool.sync.service; import com.ftptool.sync.config.ProdApiProperties; import com.ftptool.sync.config.SyncProperties; import com.ftptool.sync.model.PackageManifest; import com.ftptool.sync.model.ProdPullResult; import com.ftptool.sync.util.FileHashUtils; import com.ftptool.sync.util.FileTreeUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.FileSystemResource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @Service public class ProdConfigApiService { private static final Logger log = LoggerFactory.getLogger(ProdConfigApiService.class); private final ProdApiProperties prodApiProperties; private final SyncProperties syncProperties; private final RestTemplate restTemplate; private final WorkDirectoryService workDirectoryService; public ProdConfigApiService( ProdApiProperties prodApiProperties, SyncProperties syncProperties, RestTemplate restTemplate, WorkDirectoryService workDirectoryService ) { this.prodApiProperties = prodApiProperties; this.syncProperties = syncProperties; this.restTemplate = restTemplate; this.workDirectoryService = workDirectoryService; } public void pushPackage(PackageManifest manifest, Path zipFile) { String url = buildUrl(prodApiProperties.getPushPath()); HttpHeaders headers = defaultHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); // 当前协议约定 push 使用 multipart/form-data 上传标准同步包。 MultiValueMap body = new LinkedMultiValueMap(); body.add("file", new FileSystemResource(zipFile.toFile())); body.add("traceId", manifest.getTraceId()); body.add("direction", manifest.getDirection().name()); body.add("sourceVersion", manifest.getSourceVersion()); body.add("contentHash", manifest.getContentHash()); ResponseEntity response = restTemplate.postForEntity(url, new HttpEntity>(body, headers), String.class); if (!response.getStatusCode().is2xxSuccessful()) { throw new IllegalStateException("Prod push API failed with status " + response.getStatusCodeValue()); } log.info("Prod push API finished. traceId={}, status={}", manifest.getTraceId(), response.getStatusCodeValue()); } public ProdPullResult pullConfigSnapshot() throws IOException { String url = buildUrl(prodApiProperties.getPullPath()); HttpHeaders headers = defaultHeaders(); // 当前协议约定 pull 直接返回原始配置字节流,由同步工具落成本地文件后再回写 Git。 ResponseEntity response = restTemplate.exchange( url, HttpMethod.GET, new HttpEntity(headers), byte[].class ); if (!response.getStatusCode().is2xxSuccessful()) { throw new IllegalStateException("Prod pull API failed with status " + response.getStatusCodeValue()); } byte[] body = response.getBody(); if (body == null || body.length == 0) { throw new IllegalStateException("Prod pull API returned empty content"); } Path tempDir = Files.createTempDirectory(workDirectoryService.getProdToDevStagingDir(), "pull-"); FileTreeUtils.ensureDirectory(tempDir); Path targetFile = tempDir.resolve(syncProperties.getPullResponseFileName()); Files.write(targetFile, body); String contentHash = FileHashUtils.sha256(body); // 优先取服务端显式版本号;如果服务端没给,就退化为内容哈希做幂等判断。 String sourceVersion = firstNonBlank( response.getHeaders().getFirst("X-Config-Version"), response.getHeaders().getETag(), contentHash ); return new ProdPullResult(tempDir, sourceVersion, contentHash); } private HttpHeaders defaultHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON)); if (prodApiProperties.getToken() != null && !prodApiProperties.getToken().trim().isEmpty()) { headers.setBearerAuth(prodApiProperties.getToken().trim()); } return headers; } private String buildUrl(String path) { String base = prodApiProperties.getBaseUrl(); if (base.endsWith("/") && path.startsWith("/")) { return base.substring(0, base.length() - 1) + path; } if (!base.endsWith("/") && !path.startsWith("/")) { return base + "/" + path; } return base + path; } private String firstNonBlank(String... candidates) { for (String candidate : candidates) { if (candidate != null && !candidate.trim().isEmpty()) { return candidate; } } return null; } }