Refactor credit parse to use remote HTML paths

This commit is contained in:
wkc
2026-05-13 14:20:42 +08:00
parent b822cc202e
commit be443d1b31
18 changed files with 473 additions and 171 deletions

View File

@@ -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<String, Object> 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<String, Object> 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<CcdiDebtsInfo> 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<CcdiDebtsInfo> 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<String, Object> requireHeader(CreditParsePayload payload) {

View File

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

View File

@@ -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", "<html>ok</html>".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", "<html>a</html>".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", "<html>ok</html>".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<String, Object> 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) {

View File

@@ -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<String, Object> 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();
}
}

View File

@@ -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) {
// 忽略临时文件删除失败,避免影响主流程返回
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.lsfx.domain.response;
import lombok.Data;
@Data
public class CreditParseInvokeData {
private CreditParseResponse mappingOutputFields;
}

View File

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

View File

@@ -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> T postUrlEncodedForm(String url, Map<String, Object> params, Map<String, String> headers, Class<T> responseType) {
try {
HttpHeaders httpHeaders = createHeaders(headers);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
if (params != null) {
params.forEach((key, value) -> {
if (value != null) {
body.add(key, value.toString());
}
});
}
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, httpHeaders);
ResponseEntity<T> 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

View File

@@ -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", "<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", "<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.<Map<String, Object>>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<Map<String, Object>> paramsCaptor = ArgumentCaptor.forClass((Class) Map.class);
verify(httpUtil).postUrlEncodedForm(
eq("http://tz/api/service/interface/invokeService/xfeature"),
paramsCaptor.capture(),
isNull(),
eq(CreditParseInvokeResponse.class)
);
Map<String, Object> 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"));
}
}

View File

@@ -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 外部接口兼容逻辑。

View File

@@ -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` | 征信解析健康检查 |
## ⚠️ 错误码列表

View File

@@ -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",

View File

@@ -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"

View File

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

View File

@@ -1,7 +1,7 @@
# 开发环境配置
ruoyi:
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /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

View File

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

View File

@@ -1,7 +1,7 @@
# 开发环境配置
ruoyi:
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /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

View File

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