diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java index 9c4b4492..e02d5931 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java @@ -14,8 +14,10 @@ import com.ruoyi.info.collection.mapper.CcdiCreditInfoQueryMapper; import com.ruoyi.info.collection.mapper.CcdiCreditNegativeInfoMapper; import com.ruoyi.info.collection.mapper.CcdiDebtsInfoMapper; import com.ruoyi.info.collection.service.ICcdiCreditInfoService; +import com.ruoyi.info.collection.service.support.CreditHtmlStorageService; import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler; import com.ruoyi.lsfx.client.CreditParseClient; +import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.domain.response.CreditParsePayload; import com.ruoyi.lsfx.domain.response.CreditParseResponse; import jakarta.annotation.Resource; @@ -23,8 +25,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.File; -import java.io.IOException; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -39,6 +39,9 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService { @Resource private CreditParseClient creditParseClient; + @Resource + private CreditHtmlStorageService creditHtmlStorageService; + @Resource private CreditInfoPayloadAssembler assembler; @@ -141,37 +144,20 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService { } } - private void handleSingleFile(MultipartFile multipartFile, String userName) throws IOException { - File tempFile = createTempFile(multipartFile); - try { - CreditParseResponse response = creditParseClient.parse("LXCUSTALL", "PERSON", tempFile); - CreditParsePayload payload = requireResponse(response).getPayload(); - Map header = requireHeader(payload); - String personId = stringValue(header.get("query_cert_no")); - String personName = stringValue(header.get("query_cust_name")); - LocalDate queryDate = parseQueryDate(stringValue(header.get("report_time"))); - ensurePersonIdPresent(personId); - ensureLatestQueryDate(personId, queryDate); + private void handleSingleFile(MultipartFile multipartFile, String userName) throws Exception { + CreditHtmlStorageService.StoredCreditHtml storedHtml = creditHtmlStorageService.save(multipartFile); + CreditParseInvokeResponse response = creditParseClient.parse(storedHtml.remotePath()); + CreditParsePayload payload = requireResponse(response).getPayload(); + Map header = requireHeader(payload); + String personId = stringValue(header.get("query_cert_no")); + String personName = stringValue(header.get("query_cust_name")); + LocalDate queryDate = parseQueryDate(stringValue(header.get("report_time"))); + ensurePersonIdPresent(personId); + ensureLatestQueryDate(personId, queryDate); - List debts = assembler.buildDebts(personId, personName, queryDate, payload); - CcdiCreditNegativeInfo negative = assembler.buildNegative(personId, personName, queryDate, payload); - replaceEmployeeCredit(personId, debts, negative, userName); - } finally { - if (tempFile.exists()) { - tempFile.delete(); - } - } - } - - private File createTempFile(MultipartFile multipartFile) throws IOException { - String originalFilename = multipartFile.getOriginalFilename(); - String suffix = ".html"; - if (originalFilename != null && originalFilename.contains(".")) { - suffix = originalFilename.substring(originalFilename.lastIndexOf('.')); - } - File tempFile = File.createTempFile("credit-info-", suffix); - multipartFile.transferTo(tempFile); - return tempFile; + List debts = assembler.buildDebts(personId, personName, queryDate, payload); + CcdiCreditNegativeInfo negative = assembler.buildNegative(personId, personName, queryDate, payload); + replaceEmployeeCredit(personId, debts, negative, userName); } private void validateHtmlFile(MultipartFile file) { @@ -185,14 +171,21 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService { } } - private CreditParseResponse requireResponse(CreditParseResponse response) { - if (response == null || response.getPayload() == null) { + private CreditParseResponse requireResponse(CreditParseInvokeResponse response) { + if (response == null || response.getData() == null || response.getData().getMappingOutputFields() == null) { throw new RuntimeException("征信解析结果为空"); } - if (!"0".equals(response.getStatusCode())) { - throw new RuntimeException(stringValue(response.getMessage(), "征信解析失败")); + CreditParseResponse mappingOutputFields = response.getData().getMappingOutputFields(); + if (!Boolean.TRUE.equals(response.getSuccess()) || response.getCode() == null || response.getCode() != 1000) { + throw new RuntimeException(stringValue(mappingOutputFields.getMessage(), "征信解析平台调用失败")); } - return response; + if (!"0".equals(mappingOutputFields.getStatusCode())) { + throw new RuntimeException(stringValue(mappingOutputFields.getMessage(), "征信解析失败")); + } + if (mappingOutputFields.getPayload() == null) { + throw new RuntimeException("征信解析结果为空"); + } + return mappingOutputFields; } private Map requireHeader(CreditParsePayload payload) { diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditHtmlStorageService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditHtmlStorageService.java new file mode 100644 index 00000000..9d0a02d0 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditHtmlStorageService.java @@ -0,0 +1,44 @@ +package com.ruoyi.info.collection.service.support; + +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.file.FileUploadUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; + +/** + * 征信 HTML 服务器落盘与远程访问地址生成。 + */ +@Component +public class CreditHtmlStorageService { + + private static final String CREDIT_HTML_DIR = "credit-html"; + private static final String[] HTML_EXTENSIONS = {"html", "htm"}; + + @Value("${credit-parse.api.file-public-base-url}") + private String filePublicBaseUrl; + + public StoredCreditHtml save(MultipartFile file) throws Exception { + String profilePath = FileUploadUtils.upload(getCreditHtmlBaseDir(), file, HTML_EXTENSIONS); + return new StoredCreditHtml(profilePath, buildRemotePath(profilePath)); + } + + private String getCreditHtmlBaseDir() { + return RuoYiConfig.getProfile() + File.separator + CREDIT_HTML_DIR; + } + + private String buildRemotePath(String profilePath) { + if (StringUtils.isBlank(filePublicBaseUrl)) { + throw new IllegalStateException("征信HTML公开访问地址未配置"); + } + String normalizedBaseUrl = StringUtils.stripEnd(filePublicBaseUrl.trim(), "/"); + String normalizedProfilePath = profilePath.startsWith("/") ? profilePath : "/" + profilePath; + return normalizedBaseUrl + normalizedProfilePath; + } + + public record StoredCreditHtml(String profilePath, String remotePath) { + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java index 5cb3d501..1a82547c 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java @@ -1,5 +1,6 @@ package com.ruoyi.info.collection.service; +import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.info.collection.domain.CcdiCreditNegativeInfo; import com.ruoyi.info.collection.domain.CcdiDebtsInfo; import com.ruoyi.info.collection.domain.vo.CreditInfoUploadResultVO; @@ -7,26 +8,33 @@ import com.ruoyi.info.collection.mapper.CcdiCreditInfoQueryMapper; import com.ruoyi.info.collection.mapper.CcdiCreditNegativeInfoMapper; import com.ruoyi.info.collection.mapper.CcdiDebtsInfoMapper; import com.ruoyi.info.collection.service.impl.CcdiCreditInfoServiceImpl; +import com.ruoyi.info.collection.service.support.CreditHtmlStorageService; import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler; import com.ruoyi.lsfx.client.CreditParseClient; +import com.ruoyi.lsfx.domain.response.CreditParseInvokeData; +import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.domain.response.CreditParsePayload; import com.ruoyi.lsfx.domain.response.CreditParseResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; -import java.io.File; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDate; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; @@ -41,6 +49,9 @@ class CcdiCreditInfoServiceImplTest { @Mock private CreditParseClient creditParseClient; + @Mock + private CreditHtmlStorageService creditHtmlStorageService; + @Mock private CreditInfoPayloadAssembler assembler; @@ -54,11 +65,15 @@ class CcdiCreditInfoServiceImplTest { private CcdiCreditInfoQueryMapper queryMapper; @Test - void uploadHtmlFiles_shouldStoreCreditObjectWithoutStaffBinding() { + void uploadHtmlFiles_shouldStoreCreditObjectWithoutStaffBinding() throws Exception { MockMultipartFile file = new MockMultipartFile( "files", "family.html", "text/html", "ok".getBytes(StandardCharsets.UTF_8)); - when(creditParseClient.parse(anyString(), anyString(), any(File.class))) + when(creditHtmlStorageService.save(any())) + .thenReturn(new CreditHtmlStorageService.StoredCreditHtml( + "/profile/credit-html/2026/05/12/family_1.html", + "http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html")); + when(creditParseClient.parse(anyString())) .thenReturn(successResponse("330101199202020022", "李四", "2026-03-24")); when(assembler.buildDebts(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class))) .thenReturn(List.of(buildDebt("330101199202020022"))); @@ -69,15 +84,20 @@ class CcdiCreditInfoServiceImplTest { assertEquals(1, result.getSuccessCount()); assertEquals(0, result.getFailureCount()); + verify(creditParseClient).parse("http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html"); verify(debtsInfoMapper).deleteByPersonId("330101199202020022"); verify(negativeInfoMapper).deleteByPersonId("330101199202020022"); } @Test - void uploadHtmlFiles_shouldRejectOlderReportDate() { + void uploadHtmlFiles_shouldRejectOlderReportDate() throws Exception { MockMultipartFile file = new MockMultipartFile("files", "a.html", "text/html", "a".getBytes(StandardCharsets.UTF_8)); - when(creditParseClient.parse(anyString(), anyString(), any(File.class))) + when(creditHtmlStorageService.save(any())) + .thenReturn(new CreditHtmlStorageService.StoredCreditHtml( + "/profile/credit-html/2026/05/12/a_1.html", + "http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html")); + when(creditParseClient.parse(anyString())) .thenReturn(successResponse("330101199001010011", "张三", "2026-03-03")); when(queryMapper.selectLatestQueryDate("330101199001010011")) .thenReturn(LocalDate.parse("2026-03-05")); @@ -88,7 +108,29 @@ class CcdiCreditInfoServiceImplTest { assertEquals("上传征信日期早于当前已维护最新记录", result.getFailures().get(0).getReason()); } - private CreditParseResponse successResponse(String personId, String personName, String reportTime) { + @Test + void creditHtmlStorage_shouldStoreHtmlUnderProfileAndBuildRemotePath(@TempDir Path profileDir) throws Exception { + String oldProfile = RuoYiConfig.getProfile(); + new RuoYiConfig().setProfile(profileDir.toString()); + try { + CreditHtmlStorageService storageService = new CreditHtmlStorageService(); + ReflectionTestUtils.setField(storageService, "filePublicBaseUrl", "http://127.0.0.1:62318/"); + MockMultipartFile file = new MockMultipartFile( + "files", "credit.html", "text/html", "ok".getBytes(StandardCharsets.UTF_8)); + + CreditHtmlStorageService.StoredCreditHtml storedHtml = storageService.save(file); + + assertTrue(storedHtml.profilePath().startsWith("/profile/credit-html/")); + assertTrue(storedHtml.profilePath().endsWith(".html")); + assertEquals("http://127.0.0.1:62318" + storedHtml.profilePath(), storedHtml.remotePath()); + Path savedFile = profileDir.resolve(storedHtml.profilePath().substring("/profile/".length())); + assertTrue(Files.exists(savedFile)); + } finally { + new RuoYiConfig().setProfile(oldProfile); + } + } + + private CreditParseInvokeResponse successResponse(String personId, String personName, String reportTime) { CreditParsePayload payload = new CreditParsePayload(); Map header = new HashMap<>(); header.put("query_cert_no", personId); @@ -99,9 +141,18 @@ class CcdiCreditInfoServiceImplTest { payload.setLxPublictype(Map.of("civil_cnt", 1)); CreditParseResponse response = new CreditParseResponse(); + response.setMessage("成功"); response.setStatusCode("0"); response.setPayload(payload); - return response; + + CreditParseInvokeData data = new CreditParseInvokeData(); + data.setMappingOutputFields(response); + + CreditParseInvokeResponse invokeResponse = new CreditParseInvokeResponse(); + invokeResponse.setSuccess(true); + invokeResponse.setCode(1000); + invokeResponse.setData(data); + return invokeResponse; } private CcdiDebtsInfo buildDebt(String personId) { diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java index c03be8c5..6f5237c7 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java @@ -1,6 +1,8 @@ package com.ruoyi.lsfx.client; -import com.ruoyi.lsfx.domain.response.CreditParseResponse; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.exception.LsfxApiException; import com.ruoyi.lsfx.util.HttpUtil; import jakarta.annotation.Resource; @@ -8,7 +10,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.io.File; import java.util.HashMap; import java.util.Map; @@ -22,26 +23,51 @@ public class CreditParseClient { @Value("${credit-parse.api.url}") private String creditParseUrl; - public CreditParseResponse parse(String model, String hType, File file) { + @Value("${credit-parse.api.org-code:902000}") + private String orgCode; + + @Value("${credit-parse.api.run-type:1}") + private String runType; + + @Value("${credit-parse.api.model:LXCUSTALL}") + private String defaultModel; + + public CreditParseInvokeResponse parse(String remotePath) { + return parse(defaultModel, remotePath); + } + + public CreditParseInvokeResponse parse(String model, String remotePath) { long startTime = System.currentTimeMillis(); - log.info("【征信解析】开始调用: fileName={}, model={}, hType={}", file.getName(), model, hType); + String actualModel = StringUtils.isBlank(model) ? defaultModel : model; + log.info("【征信解析】开始调用: model={}, remotePath={}", actualModel, remotePath); try { Map params = new HashMap<>(); - params.put("model", model); - params.put("hType", hType); - params.put("file", file); + params.put("serialNum", buildSerialNum()); + params.put("orgCode", orgCode); + params.put("runType", runType); + params.put("remotePath", remotePath); + params.put("model", actualModel); - CreditParseResponse response = httpUtil.uploadFile(creditParseUrl, params, null, CreditParseResponse.class); + CreditParseInvokeResponse response = httpUtil.postUrlEncodedForm( + creditParseUrl, params, null, CreditParseInvokeResponse.class); long elapsed = System.currentTimeMillis() - startTime; - log.info("【征信解析】调用完成: statusCode={}, cost={}ms", - response != null ? response.getStatusCode() : null, elapsed); + log.info("【征信解析】调用完成: success={}, code={}, businessStatusCode={}, cost={}ms", + response != null ? response.getSuccess() : null, + response != null ? response.getCode() : null, + response != null && response.getData() != null && response.getData().getMappingOutputFields() != null + ? response.getData().getMappingOutputFields().getStatusCode() : null, + elapsed); return response; } catch (Exception e) { - log.error("【征信解析】调用失败: fileName={}, model={}, hType={}, error={}", - file.getName(), model, hType, e.getMessage(), e); + log.error("【征信解析】调用失败: model={}, remotePath={}, error={}", + actualModel, remotePath, e.getMessage(), e); throw new LsfxApiException("征信解析调用失败: " + e.getMessage(), e); } } + + private String buildSerialNum() { + return "CCDI_CREDIT_" + System.currentTimeMillis() + "_" + IdUtils.fastSimpleUUID(); + } } diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java index b40e58a0..287a16c5 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java @@ -4,7 +4,7 @@ import com.ruoyi.common.annotation.Anonymous; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.lsfx.client.CreditParseClient; -import com.ruoyi.lsfx.domain.response.CreditParseResponse; +import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.exception.LsfxApiException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -14,13 +14,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; @Tag(name = "征信解析接口测试", description = "用于测试征信解析接口") @Anonymous @@ -29,56 +22,27 @@ import java.nio.file.StandardCopyOption; public class CreditParseController { private static final String DEFAULT_MODEL = "LXCUSTALL"; - private static final String DEFAULT_HTYPE = "PERSON"; @Resource private CreditParseClient creditParseClient; - @Operation(summary = "解析征信HTML", description = "上传征信HTML文件并调用外部解析服务") + @Operation(summary = "解析征信HTML", description = "传入征信HTML远程地址并调用外部解析服务") @PostMapping("/parse") - public AjaxResult parse(@Parameter(description = "征信HTML文件") @RequestParam("file") MultipartFile file, - @Parameter(description = "解析模型,默认LXCUSTALL") @RequestParam(required = false) String model, - @Parameter(description = "主体类型,默认PERSON") @RequestParam(required = false) String hType) { - if (file == null || file.isEmpty()) { - return AjaxResult.error("征信HTML文件不能为空"); - } - - String originalFilename = file.getOriginalFilename(); - if (StringUtils.isBlank(originalFilename)) { - return AjaxResult.error("文件名不能为空"); - } - - String lowerCaseName = originalFilename.toLowerCase(); - if (!lowerCaseName.endsWith(".html") && !lowerCaseName.endsWith(".htm")) { - return AjaxResult.error("仅支持 HTML 格式文件"); + public AjaxResult parse(@Parameter(description = "征信HTML远程访问地址") @RequestParam("remotePath") String remotePath, + @Parameter(description = "模型编码,默认LXCUSTALL") @RequestParam(required = false) String model) { + if (StringUtils.isBlank(remotePath)) { + return AjaxResult.error("征信HTML远程地址不能为空"); } String actualModel = StringUtils.isBlank(model) ? DEFAULT_MODEL : model; - String actualHType = StringUtils.isBlank(hType) ? DEFAULT_HTYPE : hType; - Path tempFile = null; try { - String suffix = lowerCaseName.endsWith(".htm") ? ".htm" : ".html"; - tempFile = Files.createTempFile("credit_parse_", suffix); - Files.copy(file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING); - - File convertedFile = tempFile.toFile(); - CreditParseResponse response = creditParseClient.parse(actualModel, actualHType, convertedFile); + CreditParseInvokeResponse response = creditParseClient.parse(actualModel, remotePath); return AjaxResult.success(response); } catch (LsfxApiException e) { return AjaxResult.error(e.getMessage()); - } catch (IOException e) { - return AjaxResult.error("文件转换失败:" + e.getMessage()); } catch (Exception e) { return AjaxResult.error("征信解析失败:" + e.getMessage()); - } finally { - if (tempFile != null) { - try { - Files.deleteIfExists(tempFile); - } catch (IOException ignored) { - // 忽略临时文件删除失败,避免影响主流程返回 - } - } } } } diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseInvokeData.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseInvokeData.java new file mode 100644 index 00000000..d8d7826e --- /dev/null +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseInvokeData.java @@ -0,0 +1,9 @@ +package com.ruoyi.lsfx.domain.response; + +import lombok.Data; + +@Data +public class CreditParseInvokeData { + + private CreditParseResponse mappingOutputFields; +} diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseInvokeResponse.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseInvokeResponse.java new file mode 100644 index 00000000..141faa06 --- /dev/null +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseInvokeResponse.java @@ -0,0 +1,13 @@ +package com.ruoyi.lsfx.domain.response; + +import lombok.Data; + +@Data +public class CreditParseInvokeResponse { + + private Boolean success; + + private Integer code; + + private CreditParseInvokeData data; +} diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java index fecc5d49..966578c9 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java @@ -206,6 +206,47 @@ public class HttpUtil { } } + /** + * 发送POST请求(application/x-www-form-urlencoded格式,带请求头) + * @param url 请求URL + * @param params 表单参数 + * @param headers 请求头 + * @param responseType 响应类型 + * @return 响应对象 + */ + public T postUrlEncodedForm(String url, Map params, Map headers, Class responseType) { + try { + HttpHeaders httpHeaders = createHeaders(headers); + httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + if (params != null) { + params.forEach((key, value) -> { + if (value != null) { + body.add(key, value.toString()); + } + }); + } + + HttpEntity> requestEntity = new HttpEntity<>(body, httpHeaders); + + ResponseEntity response = restTemplate.postForEntity(url, requestEntity, responseType); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode()); + } + + T responseBody = response.getBody(); + if (responseBody == null) { + throw new LsfxApiException("API返回数据为空"); + } + + return responseBody; + } catch (RestClientException e) { + throw new LsfxApiException("网络请求失败: " + e.getMessage(), e); + } + } + /** * 上传文件(Multipart格式) * @param url 请求URL diff --git a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java index 8fe69e9a..bd3f4888 100644 --- a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java +++ b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java @@ -2,23 +2,28 @@ package com.ruoyi.lsfx.controller; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.lsfx.client.CreditParseClient; -import com.ruoyi.lsfx.domain.response.CreditParseResponse; +import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.exception.LsfxApiException; +import com.ruoyi.lsfx.util.HttpUtil; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; -import java.io.File; -import java.nio.charset.StandardCharsets; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -31,31 +36,21 @@ class CreditParseControllerTest { private CreditParseController controller; @Test - void parse_shouldRejectEmptyFile() { - AjaxResult result = controller.parse(null, null, null); + void parse_shouldRejectBlankRemotePath() { + AjaxResult result = controller.parse(null, null); assertEquals(500, result.get("code")); } @Test - void parse_shouldRejectNonHtmlFile() { - MockMultipartFile file = new MockMultipartFile( - "file", "credit.pdf", "application/pdf", "x".getBytes(StandardCharsets.UTF_8) - ); - AjaxResult result = controller.parse(file, null, null); - assertEquals(500, result.get("code")); - } + void shouldUseDefaultModelWhenMissing() { + CreditParseInvokeResponse response = new CreditParseInvokeResponse(); + response.setSuccess(true); + response.setCode(1000); - @Test - void shouldUseDefaultModelAndTypeWhenMissing() { - MockMultipartFile file = new MockMultipartFile( - "file", "credit.html", "text/html", "".getBytes(StandardCharsets.UTF_8) - ); - CreditParseResponse response = new CreditParseResponse(); - response.setStatusCode("0"); + String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html"; + when(client.parse(eq("LXCUSTALL"), eq(remotePath))).thenReturn(response); - when(client.parse(eq("LXCUSTALL"), eq("PERSON"), any(File.class))).thenReturn(response); - - AjaxResult result = controller.parse(file, null, null); + AjaxResult result = controller.parse(remotePath, null); assertEquals(200, result.get("code")); assertSame(response, result.get("data")); @@ -63,14 +58,53 @@ class CreditParseControllerTest { @Test void shouldReturnAjaxErrorWhenClientThrows() { - MockMultipartFile file = new MockMultipartFile( - "file", "credit.html", "text/html", "".getBytes(StandardCharsets.UTF_8) - ); - when(client.parse(anyString(), anyString(), any(File.class))) + when(client.parse(anyString(), anyString())) .thenThrow(new LsfxApiException("超时")); - AjaxResult result = controller.parse(file, null, null); + AjaxResult result = controller.parse("http://127.0.0.1:62318/profile/credit-html/a.html", null); assertEquals(500, result.get("code")); } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void creditParseClient_shouldPostUrlEncodedRemotePathParameters() { + HttpUtil httpUtil = mock(HttpUtil.class); + CreditParseClient parseClient = new CreditParseClient(); + ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil); + ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature"); + ReflectionTestUtils.setField(parseClient, "orgCode", "902000"); + ReflectionTestUtils.setField(parseClient, "runType", "1"); + ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL"); + + CreditParseInvokeResponse response = new CreditParseInvokeResponse(); + response.setSuccess(true); + response.setCode(1000); + when(httpUtil.postUrlEncodedForm( + eq("http://tz/api/service/interface/invokeService/xfeature"), + org.mockito.ArgumentMatchers.>any(), + isNull(), + eq(CreditParseInvokeResponse.class) + )).thenReturn(response); + + String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html"; + CreditParseInvokeResponse actual = parseClient.parse(remotePath); + + assertSame(response, actual); + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass((Class) Map.class); + verify(httpUtil).postUrlEncodedForm( + eq("http://tz/api/service/interface/invokeService/xfeature"), + paramsCaptor.capture(), + isNull(), + eq(CreditParseInvokeResponse.class) + ); + + Map params = paramsCaptor.getValue(); + assertNotNull(params.get("serialNum")); + assertTrue(params.get("serialNum").toString().startsWith("CCDI_CREDIT_")); + assertEquals("902000", params.get("orgCode")); + assertEquals("1", params.get("runType")); + assertEquals(remotePath, params.get("remotePath")); + assertEquals("LXCUSTALL", params.get("model")); + } } diff --git a/docs/reports/implementation/2026-05-12-credit-parse-remote-path-backend-implementation.md b/docs/reports/implementation/2026-05-12-credit-parse-remote-path-backend-implementation.md new file mode 100644 index 00000000..6bb3d546 --- /dev/null +++ b/docs/reports/implementation/2026-05-12-credit-parse-remote-path-backend-implementation.md @@ -0,0 +1,64 @@ +# 征信解析远程路径调用改造实施记录 + +## 背景 + +天座征信解析接口调用方式变更:不再直接 multipart 上传 HTML 文件,改为先将 HTML 保存到业务服务器可访问目录,再将完整远程访问地址作为 `remotePath` 以 `application/x-www-form-urlencoded` 表单参数提交到新接口。 + +本次按新接口文档确认输出外层结构为 `success/code/data/mappingOutputFields`,其中 `mappingOutputFields.payload` 内部仍沿用原有 `lx_header/lx_debt/lx_publictype` 结构。 + +## 修改内容 + +1. 后端上传处理 + - 上传入口 `/ccdi/creditInfo/upload` 保持不变。 + - 新增 `CreditHtmlStorageService`,校验通过后的 HTML 保存到 `ruoyi.profile/credit-html/...`。 + - 根据 `credit-parse.api.file-public-base-url` 与 `/profile/credit-html/...` 拼接生成 `remotePath`。 + - 征信维护落库逻辑改为调用 `CreditParseClient.parse(remotePath)`,后续日期校验、落库、失败记录逻辑保持原样。 + +2. 天座接口客户端 + - `CreditParseClient` 改为提交 `serialNum/orgCode/runType/remotePath/model` 表单参数。 + - `HttpUtil` 增加 `postUrlEncodedForm` 方法,统一提交 `application/x-www-form-urlencoded`。 + - 新增 `CreditParseInvokeResponse`、`CreditParseInvokeData`,承载新外层响应结构。 + - 业务解析只读取 `data.mappingOutputFields` 下的 `message/status_code/payload`。 + +3. 配置 + - `application-dev.yml`、`application-pro.yml`、`application-nas.yml`、`application-uat.yml` 同步调整 `credit-parse.api.url` 到 `/api/service/interface/invokeService/xfeature`。 + - 新增 `credit-parse.api.file-public-base-url`、`org-code=902000`、`run-type=1`、`model=LXCUSTALL`。 + +4. 本地 mock + - `lsfx-mock-server` 新增支持 `/api/service/interface/invokeService/xfeature`。 + - mock 接收新表单参数并通过 `remotePath` 读取 HTML。 + - mock 返回新外层结构,payload 仍按旧结构生成。 + +5. 测试覆盖 + - `CreditParseControllerTest` 覆盖新调试入口、默认模型、客户端异常、表单参数完整性。 + - `CcdiCreditInfoServiceImplTest` 覆盖 HTML 保存路径与 `remotePath` 拼接、旧日期拦截、成功落库、从新外层结构读取旧 payload。 + +## 验证结果 + +1. 接口文档核对 + - 已读取 `天座征信解析接口文档.xlsx`,确认调用方式为 `POST application/x-www-form-urlencoded`。 + - 已确认输入参数为 `serialNum/orgCode/runType/remotePath/model`。 + - 已确认输出结构为 `success/code/data/mappingOutputFields`。 + +2. 单元测试与编译 + - `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`:通过,4 个用例成功。 + - `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`:通过,3 个用例成功。 + - `mvn -pl ccdi-lsfx,ccdi-info-collection -am compile`:通过。 + +3. 联调验证 + - 通过 `bin/restart_java_backend.sh restart` 启动后端。 + - 启动本地 mock 后,通过 `/ccdi/creditInfo/upload` 上传临时 HTML。 + - 后端日志确认调用参数包含: + - `model=LXCUSTALL` + - `remotePath=http://127.0.0.1:62318/profile/credit-html/2026/05/12/credit_remote_path_test_20260512190228A001.html` + - mock 日志确认收到 `POST /api/service/interface/invokeService/xfeature` 并返回 200。 + - 上传结果:`successCount=1`、`failureCount=0`。 + - 列表与详情均可查询到解析后的征信负面信息和债务信息。 + - 联调测试身份证号 `330781199001019999` 对应数据已通过删除接口清理,复查列表 `total=0`。 + +## 影响范围 + +- 影响征信维护上传入口、征信解析客户端、本地 mock、相关环境配置。 +- 前端上传入口和页面接口路径不变。 +- 不保留旧 multipart 外部接口兼容逻辑。 + diff --git a/lsfx-mock-server/README.md b/lsfx-mock-server/README.md index 8e70b13a..6998d557 100644 --- a/lsfx-mock-server/README.md +++ b/lsfx-mock-server/README.md @@ -110,22 +110,31 @@ response = requests.post( ### 征信解析 Mock ```bash -curl -s -X POST http://localhost:8000/xfeature-mngs/conversation/htmlEval \ - -F model=LXCUSTALL \ - -F hType=PERSON \ - -F file=@./sample-credit.html +curl -s -X POST http://localhost:8000/api/service/interface/invokeService/xfeature \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d serialNum=CCDI_CREDIT_1 \ + -d orgCode=902000 \ + -d runType=1 \ + -d remotePath=http://127.0.0.1:62318/profile/credit-html/sample-credit.html \ + -d model=LXCUSTALL ``` 成功时返回: ```json { - "message": "成功", - "status_code": "0", - "payload": { - "lx_header": {}, - "lx_debt": {}, - "lx_publictype": {} + "success": true, + "code": 1000, + "data": { + "mappingOutputFields": { + "message": "成功", + "status_code": "0", + "payload": { + "lx_header": {}, + "lx_debt": {}, + "lx_publictype": {} + } + } } } ``` @@ -133,10 +142,13 @@ curl -s -X POST http://localhost:8000/xfeature-mngs/conversation/htmlEval \ 调试错误码时,可在 `model` 中追加错误标记: ```bash -curl -s -X POST http://localhost:8000/xfeature-mngs/conversation/htmlEval \ - -F model=error_ERR_10001 \ - -F hType=PERSON \ - -F file=@./sample-credit.html +curl -s -X POST http://localhost:8000/api/service/interface/invokeService/xfeature \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d serialNum=CCDI_CREDIT_1 \ + -d orgCode=902000 \ + -d runType=1 \ + -d remotePath=http://127.0.0.1:62318/profile/credit-html/sample-credit.html \ + -d model=error_ERR_10001 ``` 健康检查: @@ -258,7 +270,7 @@ pytest tests/ -v --cov=. --cov-report=html | 4 | POST | `/watson/api/project/upload/getpendings` | 检查解析状态 | | 5 | POST | `/watson/api/project/batchDeleteUploadFile` | 删除文件 | | 6 | POST | `/watson/api/project/getBSByLogId` | 获取银行流水 | -| 7 | POST | `/xfeature-mngs/conversation/htmlEval` | 征信解析 Mock | +| 7 | POST | `/api/service/interface/invokeService/xfeature` | 征信解析 Mock | | 8 | GET | `/credit/health` | 征信解析健康检查 | ## ⚠️ 错误码列表 diff --git a/lsfx-mock-server/main.py b/lsfx-mock-server/main.py index 3f602fce..8d60a31e 100644 --- a/lsfx-mock-server/main.py +++ b/lsfx-mock-server/main.py @@ -26,7 +26,7 @@ app = FastAPI( - **解析状态** - 轮询检查文件解析状态 - **文件删除** - 批量删除上传的文件 - **流水查询** - 分页获取银行流水数据 -- **征信解析** - 上传 HTML 并返回结构化征信 payload +- **征信解析** - 读取 HTML 远程地址并返回结构化征信 payload ### 错误模拟 @@ -40,7 +40,7 @@ app = FastAPI( 2. 上传文件: POST /watson/api/project/remoteUploadSplitFile 3. 轮询解析状态: POST /watson/api/project/upload/getpendings 4. 获取流水: POST /watson/api/project/getBSByLogId -5. 征信解析: POST /xfeature-mngs/conversation/htmlEval +5. 征信解析: POST /api/service/interface/invokeService/xfeature """, version=settings.APP_VERSION, docs_url="/docs", diff --git a/lsfx-mock-server/routers/credit_api.py b/lsfx-mock-server/routers/credit_api.py index 319ee646..37b10b79 100644 --- a/lsfx-mock-server/routers/credit_api.py +++ b/lsfx-mock-server/routers/credit_api.py @@ -1,6 +1,8 @@ from typing import Optional +from urllib.parse import urlparse +from urllib.request import urlopen -from fastapi import APIRouter, File, Form, UploadFile +from fastapi import APIRouter, Form from services.credit_debug_service import CreditDebugService from services.credit_html_identity_service import CreditHtmlIdentityService @@ -12,26 +14,30 @@ debug_service = CreditDebugService("config/credit_response_examples.json") identity_service = CreditHtmlIdentityService() -@router.post("/xfeature-mngs/conversation/htmlEval") +@router.post("/api/service/interface/invokeService/xfeature") async def html_eval( + serialNum: Optional[str] = Form(None), + orgCode: Optional[str] = Form(None), + runType: Optional[str] = Form(None), + remotePath: Optional[str] = Form(None), model: Optional[str] = Form(None), - hType: Optional[str] = Form(None), - file: Optional[UploadFile] = File(None), ): error_response = debug_service.validate_request( + serial_num=serialNum, + org_code=orgCode, + run_type=runType, + remote_path=remotePath, model=model, - h_type=hType, - file_present=file is not None, ) if error_response: return error_response - html_content = await file.read() + html_content = fetch_remote_html(remotePath) subject_identity = identity_service.extract_identity(html_content) payload = payload_service.generate_payload( model=model, - h_type=hType, - filename=file.filename or "credit.html", + h_type="PERSON", + filename=remote_filename(remotePath), subject_identity=subject_identity, ) return debug_service.build_success_response(payload) @@ -40,3 +46,14 @@ async def html_eval( @router.get("/credit/health") async def credit_health(): return {"status": "healthy", "service": "credit-mock"} + + +def fetch_remote_html(remote_path: str) -> bytes: + with urlopen(remote_path, timeout=5) as response: + return response.read() + + +def remote_filename(remote_path: str) -> str: + path = urlparse(remote_path).path + filename = path.rsplit("/", 1)[-1] + return filename or "credit.html" diff --git a/lsfx-mock-server/services/credit_debug_service.py b/lsfx-mock-server/services/credit_debug_service.py index 4ca9f2ff..b05f5389 100644 --- a/lsfx-mock-server/services/credit_debug_service.py +++ b/lsfx-mock-server/services/credit_debug_service.py @@ -9,19 +9,29 @@ class CreditDebugService: """处理征信解析接口的调试标记、参数校验与响应封装。""" VALID_MODEL = "LXCUSTALL" - VALID_HTYPES = {"PERSON", "ENTERPRISE"} def __init__(self, template_path: str): self.template_path = template_path self.templates = self._load_templates() - def validate_request(self, model: Optional[str], h_type: Optional[str], file_present: bool): + def validate_request( + self, + serial_num: Optional[str], + org_code: Optional[str], + run_type: Optional[str], + remote_path: Optional[str], + model: Optional[str], + ): + if not serial_num: + return self.build_missing_param_response("serialNum") + if not org_code: + return self.build_missing_param_response("orgCode") + if not run_type: + return self.build_missing_param_response("runType") + if not remote_path: + return self.build_missing_param_response("remotePath") if not model: return self.build_missing_param_response("model") - if not file_present: - return self.build_missing_param_response("file") - if not h_type: - return self.build_missing_param_response("hType") error_code = self.detect_error_marker(model) if error_code: @@ -29,22 +39,21 @@ class CreditDebugService: if model != self.VALID_MODEL: return self.build_error_response("ERR_10002") - if h_type not in self.VALID_HTYPES: - return self.build_error_response("ERR_10003") return None def build_success_response(self, payload: dict) -> dict: response = copy.deepcopy(self.templates["success"]) response["payload"] = payload - return response + return self.wrap_mapping_response(response) def build_missing_param_response(self, param_name: str) -> dict: response = self.build_error_response("ERR_99999") - response["message"] = response["message"].replace("XX", param_name) + mapping_output_fields = response["data"]["mappingOutputFields"] + mapping_output_fields["message"] = mapping_output_fields["message"].replace("XX", param_name) return response def build_error_response(self, error_code: str) -> dict: - return copy.deepcopy(self.templates["errors"][error_code]) + return self.wrap_mapping_response(copy.deepcopy(self.templates["errors"][error_code])) def detect_error_marker(self, model: str) -> Optional[str]: matched = re.search(r"error_(ERR_\d+)", model) @@ -55,6 +64,15 @@ class CreditDebugService: return error_code return None + def wrap_mapping_response(self, mapping_output_fields: dict) -> dict: + return { + "success": True, + "code": 1000, + "data": { + "mappingOutputFields": mapping_output_fields, + }, + } + def _load_templates(self) -> dict: template_file = Path(self.template_path) if not template_file.is_absolute(): diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 5fc30eb0..d5811189 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -1,7 +1,7 @@ # 开发环境配置 ruoyi: # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) - profile: D:/ruoyi/uploadPath + profile: tmp/uploadPath ccdi: report: @@ -149,4 +149,8 @@ lsfx: credit-parse: api: - url: http://localhost:8000/xfeature-mngs/conversation/htmlEval + url: http://localhost:8000/api/service/interface/invokeService/xfeature + file-public-base-url: http://127.0.0.1:62318 + org-code: 902000 + run-type: 1 + model: LXCUSTALL diff --git a/ruoyi-admin/src/main/resources/application-nas.yml b/ruoyi-admin/src/main/resources/application-nas.yml index 50c378f0..67b2384b 100644 --- a/ruoyi-admin/src/main/resources/application-nas.yml +++ b/ruoyi-admin/src/main/resources/application-nas.yml @@ -149,4 +149,8 @@ lsfx: credit-parse: api: - url: http://192.168.0.111:62320/xfeature-mngs/conversation/htmlEval + url: http://192.168.0.111:62320/api/service/interface/invokeService/xfeature + file-public-base-url: http://192.168.0.111:62318 + org-code: 902000 + run-type: 1 + model: LXCUSTALL diff --git a/ruoyi-admin/src/main/resources/application-pro.yml b/ruoyi-admin/src/main/resources/application-pro.yml index 4bafa8b5..426f954f 100644 --- a/ruoyi-admin/src/main/resources/application-pro.yml +++ b/ruoyi-admin/src/main/resources/application-pro.yml @@ -1,7 +1,7 @@ # 开发环境配置 ruoyi: # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) - profile: backend/uploadPath + profile: /webapps/ccdi/uploadPath ccdi: report: @@ -144,4 +144,8 @@ lsfx: credit-parse: api: - url: http://64.202.94.120:8081/xfeature-mngs/conversation/htmlEval + url: http://64.202.32.40:8083/api/service/interface/invokeService/xfeature + file-public-base-url: http://64.116.19.153 + org-code: 999000 + run-type: 1 + model: LXCUSTALL diff --git a/ruoyi-admin/src/main/resources/application-uat.yml b/ruoyi-admin/src/main/resources/application-uat.yml index 0f18c83b..9c9c1790 100644 --- a/ruoyi-admin/src/main/resources/application-uat.yml +++ b/ruoyi-admin/src/main/resources/application-uat.yml @@ -147,4 +147,8 @@ lsfx: credit-parse: api: - url: http://192.168.0.111:62320/xfeature-mngs/conversation/htmlEval + url: http://192.168.0.111:62320/api/service/interface/invokeService/xfeature + file-public-base-url: http://158.234.199.250:62318 + org-code: 902000 + run-type: 1 + model: LXCUSTALL