Implement credit parse result polling and sentinel handling

This commit is contained in:
wkc
2026-05-18 10:56:25 +08:00
parent 9917d10e59
commit 1fadb38d99
25 changed files with 918 additions and 81 deletions

View File

@@ -36,6 +36,10 @@ import java.util.Map;
@Service
public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
private static final int CREDIT_PARSE_SUCCESS_CODE = 10000;
private static final int CREDIT_PARSE_SUCCESS_STATUS = 1;
private static final int CREDIT_PARSE_SUCCESS_REASON_CODE = 200;
@Resource
private CreditParseClient creditParseClient;
@@ -179,8 +183,14 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
if (!Boolean.TRUE.equals(response.getSuccess())) {
throw new RuntimeException(stringValue(mappingOutputFields.getMessage(), "征信解析平台调用失败"));
}
if (!"0".equals(mappingOutputFields.getStatusCode())) {
throw new RuntimeException(stringValue(mappingOutputFields.getMessage(), "征信解析失败"));
if (!Integer.valueOf(CREDIT_PARSE_SUCCESS_CODE).equals(response.getCode())) {
throw new RuntimeException("征信解析平台状态码异常: " + response.getCode());
}
if (!Integer.valueOf(CREDIT_PARSE_SUCCESS_STATUS).equals(response.getData().getStatus())) {
throw new RuntimeException(parseErrorMessage(response, "征信解析状态异常: " + response.getData().getStatus()));
}
if (!Integer.valueOf(CREDIT_PARSE_SUCCESS_REASON_CODE).equals(response.getData().getReasonCode())) {
throw new RuntimeException(parseErrorMessage(response, "征信解析原因码异常: " + response.getData().getReasonCode()));
}
if (mappingOutputFields.getPayload() == null) {
throw new RuntimeException("征信解析结果为空");
@@ -188,6 +198,20 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
return mappingOutputFields;
}
private String parseErrorMessage(CreditParseInvokeResponse response, String defaultValue) {
if (response == null || response.getData() == null) {
return defaultValue;
}
String reasonMessage = stringValue(response.getData().getReasonMessage());
if (!isBlank(reasonMessage)) {
return reasonMessage;
}
if (response.getData().getMappingOutputFields() != null) {
return stringValue(response.getData().getMappingOutputFields().getMessage(), defaultValue);
}
return defaultValue;
}
private Map<String, Object> requireHeader(CreditParsePayload payload) {
Map<String, Object> header = payload.getLxHeader();
if (header == null || header.isEmpty()) {

View File

@@ -19,6 +19,8 @@ import java.util.Objects;
@Component
public class CreditInfoPayloadAssembler {
private static final BigDecimal MISSING_SENTINEL = new BigDecimal("-9999");
private static final List<DebtMapping> DEBT_MAPPINGS = List.of(
new DebtMapping("uncle_bank_house", "银行", "住房贷款", "银行", "未结清银行住房贷款"),
new DebtMapping("uncle_bank_car", "银行", "汽车贷款", "银行", "未结清银行汽车贷款"),
@@ -26,7 +28,7 @@ public class CreditInfoPayloadAssembler {
new DebtMapping("uncle_bank_consume", "银行", "消费贷款", "银行", "未结清银行消费贷款"),
new DebtMapping("uncle_bank_other", "银行", "其他贷款", "银行", "未结清银行其他贷款"),
new DebtMapping("uncle_not_bank", "非银", "非银行贷款", "非银", "未结清非银行贷款"),
new DebtMapping("uncle_credit_cart", "银行", "信用卡", "银行", "未结清信用卡")
new DebtMapping("uncle_credit_card", "银行", "信用卡", "银行", "未结清信用卡")
);
public List<CcdiDebtsInfo> buildDebts(String personId, String personName, LocalDate queryDate, CreditParsePayload payload) {
@@ -61,9 +63,13 @@ public class CreditInfoPayloadAssembler {
private CcdiDebtsInfo buildDebtRow(String personId, String personName, LocalDate queryDate,
Map<String, Object> source, DebtMapping mapping) {
Object stateValue = source.get(mapping.prefix() + "_state");
if (isMissingSentinel(stateValue)) {
return null;
}
BigDecimal principalBalance = toBigDecimal(source.get(mapping.prefix() + "_bal"));
BigDecimal debtTotalAmount = toBigDecimal(source.get(mapping.prefix() + "_lmt"));
String debtStatus = toStringValue(source.get(mapping.prefix() + "_state"));
String debtStatus = toStringValue(stateValue);
if (isEmptyMetrics(principalBalance, debtTotalAmount, debtStatus)) {
return null;
}
@@ -97,6 +103,9 @@ public class CreditInfoPayloadAssembler {
if (value == null) {
return null;
}
if (isMissingSentinel(value)) {
return null;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
@@ -111,10 +120,28 @@ public class CreditInfoPayloadAssembler {
if (value == null) {
return null;
}
if (isMissingSentinel(value)) {
return null;
}
String text = Objects.toString(value, "").trim();
return text.isEmpty() ? null : text;
}
private boolean isMissingSentinel(Object value) {
if (value == null) {
return false;
}
String text = Objects.toString(value, "").trim();
if (text.isEmpty()) {
return false;
}
try {
return new BigDecimal(text).compareTo(MISSING_SENTINEL) == 0;
} catch (NumberFormatException e) {
return false;
}
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}

View File

@@ -108,6 +108,45 @@ class CcdiCreditInfoServiceImplTest {
assertEquals("上传征信日期早于当前已维护最新记录", result.getFailures().get(0).getReason());
}
@Test
void uploadHtmlFiles_shouldRejectInvalidPlatformCode() throws Exception {
MockMultipartFile file = new MockMultipartFile("files", "a.html", "text/html", "<html>a</html>".getBytes(StandardCharsets.UTF_8));
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"));
CreditParseInvokeResponse response = successResponse("330101199001010011", "张三", "2026-03-03");
response.setCode(99999);
when(creditParseClient.parse(anyString())).thenReturn(response);
CreditInfoUploadResultVO result = service.upload(List.of(file));
assertEquals(0, result.getSuccessCount());
assertEquals("征信解析平台状态码异常: 99999", result.getFailures().get(0).getReason());
}
@Test
void uploadHtmlFiles_shouldRejectInvalidResultStatus() throws Exception {
MockMultipartFile file = new MockMultipartFile("files", "a.html", "text/html", "<html>a</html>".getBytes(StandardCharsets.UTF_8));
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"));
CreditParseInvokeResponse response = successResponse("330101199001010011", "张三", "2026-03-03");
response.getData().setStatus(0);
response.getData().setReasonCode(500);
response.getData().setReasonMessage("结果解析失败");
when(creditParseClient.parse(anyString())).thenReturn(response);
CreditInfoUploadResultVO result = service.upload(List.of(file));
assertEquals(0, result.getSuccessCount());
assertEquals("结果解析失败", result.getFailures().get(0).getReason());
}
@Test
void creditHtmlStorage_shouldStoreHtmlUnderProfileAndBuildRemotePath(@TempDir Path profileDir) throws Exception {
String oldProfile = RuoYiConfig.getProfile();
@@ -142,15 +181,17 @@ class CcdiCreditInfoServiceImplTest {
CreditParseResponse response = new CreditParseResponse();
response.setMessage("成功");
response.setStatusCode("0");
response.setStatusCode("ERR_SHOULD_IGNORE");
response.setPayload(payload);
CreditParseInvokeData data = new CreditParseInvokeData();
data.setMappingOutputFields(response);
data.setStatus(1);
data.setReasonCode(200);
CreditParseInvokeResponse invokeResponse = new CreditParseInvokeResponse();
invokeResponse.setSuccess(true);
invokeResponse.setCode(99999);
invokeResponse.setCode(10000);
invokeResponse.setData(data);
return invokeResponse;
}

View File

@@ -12,6 +12,7 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CreditInfoPayloadAssemblerTest {
@@ -28,13 +29,18 @@ class CreditInfoPayloadAssemblerTest {
debt.put("uncle_not_bank_bal", "2000");
debt.put("uncle_not_bank_lmt", "3000");
debt.put("uncle_not_bank_state", "逾期");
debt.put("uncle_credit_card_bal", "100");
debt.put("uncle_credit_card_lmt", "500");
debt.put("uncle_credit_card_state", "正常");
payload.setLxDebt(debt);
List<CcdiDebtsInfo> rows = assembler.buildDebts("330101199001010011", "张三", LocalDate.parse("2026-03-01"), payload);
assertEquals(2, rows.size());
assertEquals(3, rows.size());
assertEquals("住房贷款", rows.get(0).getDebtSubType());
assertEquals("非银", rows.get(1).getCreditorType());
assertEquals("信用卡", rows.get(2).getDebtSubType());
assertEquals(new BigDecimal("500"), rows.get(2).getDebtTotalAmount());
}
@Test
@@ -51,6 +57,42 @@ class CreditInfoPayloadAssemblerTest {
assertEquals(new BigDecimal("9800"), info.getCivilLmt());
}
@Test
void shouldSkipDebtTypeWhenStateIsMissingSentinel() {
CreditParsePayload payload = new CreditParsePayload();
Map<String, Object> debt = new HashMap<>();
debt.put("uncle_bank_house_bal", "50000");
debt.put("uncle_bank_house_lmt", "100000");
debt.put("uncle_bank_house_state", "-9999");
debt.put("uncle_not_bank_bal", "2000");
debt.put("uncle_not_bank_lmt", "3000");
debt.put("uncle_not_bank_state", "正常");
payload.setLxDebt(debt);
List<CcdiDebtsInfo> rows = assembler.buildDebts("330101199001010011", "张三", LocalDate.parse("2026-03-01"), payload);
assertEquals(1, rows.size());
assertEquals("非银行贷款", rows.get(0).getDebtSubType());
}
@Test
void shouldTreatNegativeRiskMissingSentinelAsEmptyValue() {
CreditParsePayload payload = new CreditParsePayload();
Map<String, Object> publictype = new HashMap<>();
publictype.put("civil_cnt", "-9999");
publictype.put("civil_lmt", "-9999.0");
publictype.put("enforce_cnt", 1);
publictype.put("enforce_lmt", "1200");
payload.setLxPublictype(publictype);
CcdiCreditNegativeInfo info = assembler.buildNegative("330101199001010011", "张三", LocalDate.parse("2026-03-01"), payload);
assertEquals(0, info.getCivilCnt());
assertNull(info.getCivilLmt());
assertEquals(1, info.getEnforceCnt());
assertEquals(new BigDecimal("1200"), info.getEnforceLmt());
}
@Test
void shouldSkipDebtRowWhenAllMetricsAreEmpty() {
CreditParsePayload payload = new CreditParsePayload();

View File

@@ -18,6 +18,12 @@ import java.util.Map;
@Component
public class CreditParseClient {
private static final int PLATFORM_SUCCESS_CODE = 10000;
private static final int INITIATE_SUCCESS_STATUS = 1;
private static final int INITIATE_SUCCESS_REASON_CODE = 200;
private static final int RESULT_QUERY_MAX_ATTEMPTS = 5;
private static final long RESULT_QUERY_INTERVAL_MILLIS = 2000L;
@Resource
private HttpUtil httpUtil;
@@ -27,7 +33,10 @@ public class CreditParseClient {
@Value("${credit-parse.api.url}")
private String creditParseUrl;
@Value("${credit-parse.api.org-code:902000}")
@Value("${credit-parse.api.result-url}")
private String creditParseResultUrl;
@Value("${credit-parse.api.org-code:999000}")
private String orgCode;
@Value("${credit-parse.api.run-type:1}")
@@ -43,19 +52,13 @@ public class CreditParseClient {
public CreditParseInvokeResponse parse(String model, String remotePath) {
long startTime = System.currentTimeMillis();
String actualModel = StringUtils.isBlank(model) ? defaultModel : model;
String serialNum = buildSerialNum();
try {
Map<String, Object> params = new HashMap<>();
params.put("serialNum", buildSerialNum());
params.put("orgCode", orgCode);
params.put("runType", runType);
params.put("remotePath", remotePath);
params.put("model", actualModel);
Map<String, Object> initiateParams = buildInitiateParams(serialNum, actualModel, remotePath);
CreditParseInvokeResponse initiateResponse = request(creditParseUrl, initiateParams, "发起接口");
requireSuccessfulInitiateResponse(initiateResponse, "征信解析发起接口");
log.info("【征信解析】调用请求: url={}, params={}", creditParseUrl, toJson(params));
String responseJson = httpUtil.postUrlEncodedFormForString(creditParseUrl, params, null);
log.info("【征信解析】调用返回JSON: {}", responseJson);
CreditParseInvokeResponse response = objectMapper.readValue(responseJson, CreditParseInvokeResponse.class);
CreditParseInvokeResponse response = queryResult(serialNum);
long elapsed = System.currentTimeMillis() - startTime;
log.info("【征信解析】调用完成: success={}, code={}, businessStatusCode={}, cost={}ms",
@@ -65,17 +68,136 @@ public class CreditParseClient {
? response.getData().getMappingOutputFields().getStatusCode() : null,
elapsed);
return response;
} catch (LsfxApiException e) {
log.error("【征信解析】调用失败: serialNum={}, model={}, remotePath={}, error={}",
serialNum, actualModel, remotePath, e.getMessage(), e);
throw e;
} catch (Exception e) {
log.error("【征信解析】调用失败: model={}, remotePath={}, error={}",
actualModel, remotePath, e.getMessage(), e);
log.error("【征信解析】调用失败: serialNum={}, model={}, remotePath={}, error={}",
serialNum, actualModel, remotePath, e.getMessage(), e);
throw new LsfxApiException("征信解析调用失败: " + e.getMessage(), e);
}
}
private Map<String, Object> buildInitiateParams(String serialNum, String model, String remotePath) {
Map<String, Object> params = buildBaseParams(serialNum);
params.put("remotePath", remotePath);
params.put("model", model);
return params;
}
private Map<String, Object> buildBaseParams(String serialNum) {
Map<String, Object> params = new HashMap<>();
params.put("serialNum", serialNum);
params.put("orgCode", orgCode);
params.put("runType", runType);
return params;
}
private CreditParseInvokeResponse queryResult(String serialNum) {
Map<String, Object> params = buildBaseParams(serialNum);
for (int attempt = 1; attempt <= RESULT_QUERY_MAX_ATTEMPTS; attempt++) {
CreditParseInvokeResponse response = request(creditParseResultUrl, params,
"结果接口第" + attempt + "次查询");
requireSuccessfulServiceResponse(response, "征信解析结果接口");
if (response.getData() == null || response.getData().getMappingOutputFields() == null) {
waitForNextResult(serialNum, attempt);
continue;
}
if (response.getData().getMappingOutputFields().getPayload() != null) {
return response;
}
waitForNextResult(serialNum, attempt);
}
throw new LsfxApiException("征信解析结果未返回");
}
private void waitForNextResult(String serialNum, int attempt) {
if (attempt >= RESULT_QUERY_MAX_ATTEMPTS) {
return;
}
log.info("【征信解析】结果未返回: serialNum={}, attempt={}/{}, {}ms后重试",
serialNum, attempt, RESULT_QUERY_MAX_ATTEMPTS, RESULT_QUERY_INTERVAL_MILLIS);
try {
sleepBeforeNextResultQuery(RESULT_QUERY_INTERVAL_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LsfxApiException("征信解析结果查询被中断", e);
}
}
protected void sleepBeforeNextResultQuery(long intervalMillis) throws InterruptedException {
Thread.sleep(intervalMillis);
}
private CreditParseInvokeResponse request(String url, Map<String, Object> params, String stage) {
try {
log.info("【征信解析】{}请求: url={}, params={}", stage, url, toJson(params));
String responseJson = httpUtil.postUrlEncodedFormForString(url, params, null);
log.info("【征信解析】{}返回JSON: {}", stage, responseJson);
return objectMapper.readValue(responseJson, CreditParseInvokeResponse.class);
} catch (LsfxApiException e) {
throw e;
} catch (Exception e) {
throw new LsfxApiException("征信解析" + stage + "调用失败: " + e.getMessage(), e);
}
}
private void requireSuccessfulInitiateResponse(CreditParseInvokeResponse response, String stage) {
requireSuccessfulServiceResponse(response, stage);
}
private void requireSuccessfulServiceResponse(CreditParseInvokeResponse response, String stage) {
requireSuccessfulPlatformResponse(response, stage);
if (response.getData() == null) {
throw new LsfxApiException(stage + "返回结果为空");
}
if (!Integer.valueOf(INITIATE_SUCCESS_STATUS).equals(response.getData().getStatus())) {
throw new LsfxApiException(serviceErrorMessage(response, stage + "状态异常: " + response.getData().getStatus()));
}
if (!Integer.valueOf(INITIATE_SUCCESS_REASON_CODE).equals(response.getData().getReasonCode())) {
throw new LsfxApiException(serviceErrorMessage(response, stage + "原因码异常: " + response.getData().getReasonCode()));
}
}
private void requireSuccessfulPlatformResponse(CreditParseInvokeResponse response, String stage) {
if (response == null) {
throw new LsfxApiException(stage + "返回结果为空");
}
if (!Boolean.TRUE.equals(response.getSuccess())) {
throw new LsfxApiException(stage + "平台调用失败");
}
if (!Integer.valueOf(PLATFORM_SUCCESS_CODE).equals(response.getCode())) {
throw new LsfxApiException(stage + "平台状态码异常: " + response.getCode());
}
}
private String buildSerialNum() {
return "CCDI_CREDIT_" + System.currentTimeMillis() + "_" + IdUtils.fastSimpleUUID();
}
private String stringValue(Object value, String defaultValue) {
if (value == null) {
return defaultValue;
}
String text = value.toString().trim();
return text.isEmpty() ? defaultValue : text;
}
private String serviceErrorMessage(CreditParseInvokeResponse response, String defaultValue) {
if (response == null || response.getData() == null) {
return defaultValue;
}
String reasonMessage = stringValue(response.getData().getReasonMessage(), null);
if (reasonMessage != null) {
return reasonMessage;
}
if (response.getData().getMappingOutputFields() != null) {
return stringValue(response.getData().getMappingOutputFields().getMessage(), defaultValue);
}
return defaultValue;
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);

View File

@@ -1,9 +1,17 @@
package com.ruoyi.lsfx.domain.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CreditParseInvokeData {
private CreditParseResponse mappingOutputFields;
private Integer status;
private Integer reasonCode;
private String reasonMessage;
}

View File

@@ -14,16 +14,20 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
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.junit.jupiter.api.Assertions.assertThrows;
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.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -46,7 +50,7 @@ class CreditParseControllerTest {
void shouldUseDefaultModelWhenMissing() {
CreditParseInvokeResponse response = new CreditParseInvokeResponse();
response.setSuccess(true);
response.setCode(1000);
response.setCode(10000);
String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html";
when(client.parse(eq("LXCUSTALL"), eq(remotePath))).thenReturn(response);
@@ -69,13 +73,14 @@ class CreditParseControllerTest {
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
void creditParseClient_shouldParseSuccessResponseWithStringPayload() throws Exception {
void creditParseClient_shouldInitiateAndQueryResultWithSameSerialNum() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
CreditParseClient parseClient = new CreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "orgCode", "902000");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
@@ -86,8 +91,12 @@ class CreditParseControllerTest {
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"0\",\"payload\":"
+ objectMapper.writeValueAsString(payload) + "}}}");
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE"));
String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html";
CreditParseInvokeResponse actual = parseClient.parse(remotePath);
@@ -96,19 +105,188 @@ class CreditParseControllerTest {
assertEquals(10000, actual.getCode());
assertEquals("330101199001010011", actual.getData().getMappingOutputFields()
.getPayload().getLxHeader().get("query_cert_no"));
ArgumentCaptor<Map<String, Object>> paramsCaptor = ArgumentCaptor.forClass((Class) Map.class);
ArgumentCaptor<Map<String, Object>> initiateParamsCaptor = ArgumentCaptor.forClass((Class) Map.class);
verify(httpUtil).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
paramsCaptor.capture(),
initiateParamsCaptor.capture(),
isNull()
);
ArgumentCaptor<Map<String, Object>> resultParamsCaptor = ArgumentCaptor.forClass((Class) Map.class);
verify(httpUtil).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
resultParamsCaptor.capture(),
isNull()
);
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"));
Map<String, Object> initiateParams = initiateParamsCaptor.getValue();
Map<String, Object> resultParams = resultParamsCaptor.getValue();
assertNotNull(initiateParams.get("serialNum"));
assertTrue(initiateParams.get("serialNum").toString().startsWith("CCDI_CREDIT_"));
assertEquals(initiateParams.get("serialNum"), resultParams.get("serialNum"));
assertEquals("999000", initiateParams.get("orgCode"));
assertEquals("999000", resultParams.get("orgCode"));
assertEquals("1", initiateParams.get("runType"));
assertEquals("1", resultParams.get("runType"));
assertEquals(remotePath, initiateParams.get("remotePath"));
assertEquals("LXCUSTALL", initiateParams.get("model"));
assertEquals(false, resultParams.containsKey("remotePath"));
assertEquals(false, resultParams.containsKey("model"));
}
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
void creditParseClient_shouldRetryEmptyPayloadFiveTimesWithTwoSecondInterval() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
String emptyPayloadResponse = "{\"success\":true,\"code\":10000,\"data\":{\"status\":1,\"reasonCode\":200,\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"ERR_SHOULD_IGNORE\"}}}";
String payload = "{\"lx_header\":{\"query_cert_no\":\"330101199001010011\",\"query_cust_name\":\"张三\",\"report_time\":\"2026-03-24\"}}";
String resultResponse = resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE");
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, resultResponse);
CreditParseInvokeResponse actual = parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html");
assertEquals("330101199001010011", actual.getData().getMappingOutputFields()
.getPayload().getLxHeader().get("query_cert_no"));
verify(httpUtil, times(5)).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
);
assertEquals(List.of(2000L, 2000L, 2000L, 2000L), parseClient.getSleepIntervals());
}
@Test
void creditParseClient_shouldRejectInvalidOuterCode() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn("{\"success\":true,\"code\":99999,\"data\":{\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"0\"}}}");
LsfxApiException exception = assertThrows(LsfxApiException.class,
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
assertTrue(exception.getMessage().contains("平台状态码异常"));
}
@Test
void creditParseClient_shouldRejectFailedResultStatus() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"reasonMessage\":\"解析失败\",\"reasonCode\":500,\"status\":0,\"mappingOutputFields\":{\"message\":\"结果异常\"}}}");
LsfxApiException exception = assertThrows(LsfxApiException.class,
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
assertTrue(exception.getMessage().contains("解析失败"));
}
@Test
void creditParseClient_shouldRejectFailedInitiateStatus() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"mappingOutputFields\":{\"message\":\"文件写入失败\"},\"reasonMessage\":\"文件写入失败\",\"reasonCode\":500,\"status\":0}}");
LsfxApiException exception = assertThrows(LsfxApiException.class,
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
assertTrue(exception.getMessage().contains("文件写入失败"));
verify(httpUtil, times(0)).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
);
}
private static String initiateSuccessResponse() {
return "{\"success\":true,\"code\":10000,\"data\":{\"traceId\":\"TRACE-001\","
+ "\"mappingOutputFields\":{\"message\":\"文件写入成功,流水号为: CCDI_CREDIT_TEST\"},"
+ "\"reasonMessage\":\"Running successfully\",\"procCode\":\"999000\","
+ "\"reasonCode\":200,\"status\":1}}";
}
private static String resultSuccessResponse(ObjectMapper objectMapper, String payload, String statusCode) throws Exception {
return "{\"success\":true,\"code\":10000,\"data\":{\"status\":1,\"reasonCode\":200,"
+ "\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"" + statusCode + "\",\"payload\":"
+ objectMapper.writeValueAsString(payload) + "}}}";
}
private static class TestableCreditParseClient extends CreditParseClient {
private final List<Long> sleepIntervals = new ArrayList<>();
@Override
protected void sleepBeforeNextResultQuery(long intervalMillis) {
sleepIntervals.add(intervalMillis);
}
private List<Long> getSleepIntervals() {
return sleepIntervals;
}
}
}

View File

@@ -196,7 +196,7 @@
| `uncle_bank_consume` | 银行 | 消费贷款 | 银行 | 未结清银行消费贷款 |
| `uncle_bank_other` | 银行 | 其他贷款 | 银行 | 未结清银行其他贷款 |
| `uncle_not_bank` | 非银 | 非银行贷款 | 非银 | 未结清非银行贷款 |
| `uncle_credit_cart` | 银行 | 信用卡 | 银行 | 未结清信用卡 |
| `uncle_credit_card` | 银行 | 信用卡 | 银行 | 未结清信用卡 |
字段映射规则:
@@ -206,6 +206,7 @@
落库过滤规则:
- 当某组 `*_state``-9999` 时,表示不存在该类型负债,不生成明细记录
- 当某组 `principal_balance``debt_total_amount` 都为空或 `0`,且 `debt_status` 为空时,不生成明细记录
- 其余情况生成一条明细
@@ -220,6 +221,8 @@
- `enforce_lmt` -> `enforce_lmt`
- `adm_lmt` -> `adm_lmt`
负面风险字段值为 `-9999``-9999.0` 时,表示不存在对应风险类型;次数按 `0` 处理,金额按空值处理。
## 10. 最新征信判定与覆盖策略
系统只保留员工最新征信,规则如下:

View File

@@ -0,0 +1,35 @@
# 征信解析双接口与结果轮询后端实施计划
## 背景
根据 `天座征信解析接口文档.xlsx`,征信解析需要拆分为发起接口和结果接口:先通过 `/api/service/interface/invokeService/xfeature` 提交 HTML 远程地址,再通过 `/api/service/interface/invokeService/xfeatureResult` 按同一业务流水号查询解析结果。
## 实施内容
1. 后端客户端
- 保持 `CreditParseClient.parse(remotePath)``parse(model, remotePath)` 对外签名不变。
- 内部生成同一个 `serialNum`,发起接口提交 `serialNum/orgCode/runType/remotePath/model`
- 结果接口提交 `serialNum/orgCode/runType`,最多查询 5 次,每次间隔 2 秒。
- 外层严格校验 `success=true``code=10000`,业务层严格校验 `status_code=0`
- 仅当结果未就绪或 `payload` 为空时继续轮询,明确业务失败立即返回失败原因。
2. 配置
- 保留 `credit-parse.api.url` 作为发起接口地址。
- 新增 `credit-parse.api.result-url` 作为结果接口地址。
- `dev/uat/nas/pro` 环境 `credit-parse.api.org-code` 统一调整为 `999000`
3. Mock 服务
- `/xfeature` 读取 `remotePath` 生成 payload`serialNum` 暂存结果,只返回发起成功结构。
- `/xfeatureResult``serialNum` 返回暂存 payload未知流水号返回业务失败。
4. 征信维护落库
- 上传入口、HTML 落盘、`remotePath` 拼接、页面接口均保持不变。
- 原有落库逻辑继续读取最终结果中的 `lx_header/lx_debt/lx_publictype`
## 验证计划
- `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`
- `mvn -pl ccdi-lsfx,ccdi-info-collection -am compile`
- `cd lsfx-mock-server && pytest tests/test_credit_api.py tests/test_startup.py -q`

View File

@@ -0,0 +1,56 @@
# 征信解析双接口与结果轮询实施记录
## 背景
本次按 `天座征信解析接口文档.xlsx` 调整征信解析调用方式:由单次发起接口直接获取 `payload`,改为发起接口 `/api/service/interface/invokeService/xfeature` 加结果接口 `/api/service/interface/invokeService/xfeatureResult` 的两步调用。
## 修改内容
1. `CreditParseClient`
- 保持原有 `parse` 方法签名不变。
- 发起接口提交 `serialNum/orgCode/runType/remotePath/model`
- 结果接口使用同一 `serialNum` 提交 `serialNum/orgCode/runType`
- 结果接口最多轮询 5 次,每次间隔 2 秒。
- 严格校验外层 `success=true``code=10000` 和业务层 `status_code=0`
2. 配置
- `application-dev.yml``application-uat.yml``application-nas.yml``application-pro.yml` 新增 `credit-parse.api.result-url`
- 上述环境的 `credit-parse.api.org-code` 统一调整为 `999000`
3. `CcdiCreditInfoServiceImpl`
- 征信维护落库前补充外层 `code=10000` 校验。
- 上传入口、HTML 保存、`remotePath` 生成和落库字段映射保持不变。
4. `lsfx-mock-server`
- `/xfeature` 改为发起接口,只暂存结果并返回发起成功结构。
- 新增 `/xfeatureResult`,按 `serialNum` 返回暂存 payload。
- Mock README 和启动路由说明同步更新。
5. 测试
- Java 测试补充同一 `serialNum` 双接口调用、5 次轮询、2 秒间隔和外层状态码校验。
- Mock 测试补充发起接口、结果接口、缺参、未知流水号和 payload 返回。
## 影响范围
- 影响征信解析客户端、征信维护上传后的解析调用、征信解析 Mock 服务和相关环境配置。
- 不涉及前端页面、前端 API、数据库表结构和业务 payload 字段变更。
## 验证
- `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`通过6 个用例成功。
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过4 个用例成功。
- `mvn -pl ccdi-lsfx,ccdi-info-collection -am compile`:通过。
- `cd lsfx-mock-server && python3 -m pytest tests/test_credit_api.py tests/test_startup.py -q`通过9 个用例成功。
- `git diff --check`:通过。
- 后端联调:
- 启动 `lsfx-mock-server`,并通过 `bin/restart_java_backend.sh restart` 重启后端。
- 调用 `/login/test` 获取测试令牌后,通过 `/ccdi/creditInfo/upload` 上传本轮 HTML 测试文件。
- 上传结果:`totalCount=1``successCount=1``failureCount=0`
- 列表按身份证号 `330781199001019914` 查询到 1 条征信记录。
- 后端日志确认同一个 `serialNum` 先调用 `/api/service/interface/invokeService/xfeature`,再调用 `/api/service/interface/invokeService/xfeatureResult`
- 调用删除接口清理本轮测试数据,回查列表 `total=0`
- 本轮启动的 mock 和后端进程已关闭。
- 浏览器验证说明:
- 已使用 browser-use 打开本地前端并完成登录。
- 后端重启后,浏览器旧登录态触发接口 500且 browser-use 安全策略阻止通过 `javascript:` URL 清理本地状态,因此未继续执行页面上传动作。
- 已改用真实后端接口完成上传、落库、日志和清理闭环验证。

View File

@@ -0,0 +1,30 @@
# 征信解析信用卡字段前缀调整实施记录
## 背景
征信解析负债字段中,信用卡字段前缀需要由 `uncle_credit_cart_*` 调整为 `uncle_credit_card_*`,保持字段命名与信用卡含义一致。
## 修改内容
1. 后端字段装配
- `CreditInfoPayloadAssembler` 中信用卡负债映射前缀由 `uncle_credit_cart` 调整为 `uncle_credit_card`
2. Mock 字段生成
- `lsfx-mock-server/config/credit_feature_schema.json` 中信用卡字段同步调整为:
- `uncle_credit_card_bal`
- `uncle_credit_card_lmt`
- `uncle_credit_card_state`
3. 文档
- `docs/design/2026-03-23-credit-info-maintenance-design.md` 中信用卡字段前缀同步调整。
## 影响范围
- 仅影响征信解析 payload 中信用卡负债字段的读取与本地 Mock 生成字段。
- 不涉及接口成功判断、数据库结构、前端页面和其他负债类型。
## 验证
- `mvn -pl ccdi-info-collection -am -Dtest=CreditInfoPayloadAssemblerTest,CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过10 个用例成功。
- `cd lsfx-mock-server && python3 -m pytest tests/test_credit_api.py tests/test_startup.py -q`通过9 个用例成功。
- `git diff --check`:通过。

View File

@@ -0,0 +1,36 @@
# 征信解析缺失标记过滤实施记录
## 背景
征信解析返回中,负债字段的 `*_state` 若为 `-9999`,表示不存在该类型负债;负面风险字段中 `-9999``-9999.0` 也表示不存在对应风险类型,不能按真实负债或负面风险指标落库。
## 修改内容
1. 负债明细装配
- `CreditInfoPayloadAssembler` 中新增 `-9999` 缺失标记识别。
- 当某组负债的 `*_state``-9999` 时,直接跳过该负债类型,不生成 `ccdi_debts_info` 明细。
- 数值和状态转换过程中同步将 `-9999` 视为空值,避免缺失标记落库。
2. 负面风险装配
- `lx_publictype` 中次数字段为 `-9999` 时按 `0` 处理。
- `lx_publictype` 中金额字段为 `-9999``-9999.0` 时按空值处理。
3. 测试
- 补充 `*_state=-9999` 时跳过负债类型的单测。
- 补充负面风险 `-9999` 转换为 `0/null` 的单测。
4. 文档
- 更新 `docs/design/2026-03-23-credit-info-maintenance-design.md` 中负债过滤和负面风险缺失值规则。
## 影响范围
- 仅影响征信解析 payload 到负债明细、负面风险表的装配逻辑。
- 不涉及接口调用、成功判断、数据库结构和前端页面。
## 验证
- `mvn -pl ccdi-info-collection -am -Dtest=CreditInfoPayloadAssemblerTest,CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过10 个用例成功。
- `git diff --check`:通过。
- 使用 `/Users/wkc/Downloads/zxjx.txt` 按当前规则复核:
- 负债明细仅保留 `uncle_bank_manage``uncle_not_bank` 两类。
- 负面风险 `civil/enforce/adm``-9999` 均按无对应风险处理。

View File

@@ -0,0 +1,39 @@
# 征信解析发起接口成功判断调整实施记录
## 背景
联调日志显示,征信解析发起接口 `/api/service/interface/invokeService/xfeature` 已返回外层 `success=true``code=10000`,并在 `data.mappingOutputFields.message` 中返回“文件写入成功,流水号为...”。但系统仍按结果接口的 `mappingOutputFields.status_code=0` 判断发起接口成功,导致“文件写入成功”被误判为失败,后续结果接口未继续查询。
## 修改内容
1. `CreditParseClient`
- 发起接口改为校验外层 `success=true``code=10000`,并校验发起层 `data.status=1``data.reasonCode=200`
- 结果接口改为与发起接口一致,校验外层 `success=true``code=10000`,并校验 `data.status=1``data.reasonCode=200`
- `mappingOutputFields.status_code` 不再作为 `/xfeatureResult` 是否成功的判断条件;结果接口返回成功后,仅通过 `payload` 是否为空判断是否继续轮询。
- 接口异常时优先返回 `reasonMessage`,其次返回 `mappingOutputFields.message`
2. 响应对象
- `CreditParseInvokeData` 新增 `status``reasonCode``reasonMessage` 字段。
-`data` 层开启未知字段忽略,适配发起接口返回的 `traceId``procCode``bizId` 等额外字段。
3. Mock 与文档
- `lsfx-mock-server` 发起接口和结果接口成功返回均补充 `status=1``reasonCode=200`
- Mock README 示例同步更新。
4. 测试
- Java 单测改为使用发起接口真实成功结构。
- 新增发起接口 `status/reasonCode` 失败时不继续查询结果接口的断言。
- Java 单测增加 `/xfeatureResult` 返回 `status_code` 非 0 但 `status/reasonCode` 成功时仍继续落库的覆盖。
- Mock 测试同步校验发起接口和结果接口的 `data.status=1``data.reasonCode=200`
## 影响范围
- 仅影响征信解析双接口调用链的成功判断与本地 Mock 返回结构。
- 不涉及前端页面、数据库结构、征信 payload 字段映射和落库逻辑。
## 验证
- `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`通过8 个用例成功。
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过5 个用例成功。
- `cd lsfx-mock-server && python3 -m pytest tests/test_credit_api.py tests/test_startup.py -q`通过9 个用例成功。
- `git diff --check`:通过。

View File

@@ -113,18 +113,50 @@ response = requests.post(
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 orgCode=999000 \
-d runType=1 \
-d remotePath=http://127.0.0.1:62318/profile/credit-html/sample-credit.html \
-d model=LXCUSTALL
```
成功时返回:
发起成功时返回:
```json
{
"success": true,
"code": 1000,
"code": 10000,
"data": {
"mappingOutputFields": {
"message": "文件写入成功,流水号为: CCDI_CREDIT_1"
},
"reasonMessage": "Running successfully",
"outputFields": {
"C_S_ZXJXMESSAGE": "文件写入成功,流水号为: CCDI_CREDIT_1"
},
"procCode": "999000",
"bizId": "CCDI_CREDIT_1",
"reasonCode": 200,
"status": 1
}
}
```
再用相同 `serialNum` 查询解析结果:
```bash
curl -s -X POST http://localhost:8000/api/service/interface/invokeService/xfeatureResult \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d serialNum=CCDI_CREDIT_1 \
-d orgCode=999000 \
-d runType=1
```
结果成功时返回:
```json
{
"success": true,
"code": 10000,
"data": {
"mappingOutputFields": {
"message": "成功",
@@ -134,7 +166,10 @@ curl -s -X POST http://localhost:8000/api/service/interface/invokeService/xfeatu
"lx_debt": {},
"lx_publictype": {}
}
}
},
"reasonMessage": "成功",
"reasonCode": 200,
"status": 1
}
}
```
@@ -145,7 +180,7 @@ curl -s -X POST http://localhost:8000/api/service/interface/invokeService/xfeatu
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 orgCode=999000 \
-d runType=1 \
-d remotePath=http://127.0.0.1:62318/profile/credit-html/sample-credit.html \
-d model=error_ERR_10001
@@ -270,8 +305,9 @@ 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 | `/api/service/interface/invokeService/xfeature` | 征信解析 Mock |
| 8 | GET | `/credit/health` | 征信解析健康检查 |
| 7 | POST | `/api/service/interface/invokeService/xfeature` | 征信解析发起 Mock |
| 8 | POST | `/api/service/interface/invokeService/xfeatureResult` | 征信解析结果 Mock |
| 9 | GET | `/credit/health` | 征信解析健康检查 |
## ⚠️ 错误码列表

View File

@@ -112,17 +112,17 @@
},
{
"domain": "lx_debt",
"field": "uncle_credit_cart_bal",
"field": "uncle_credit_card_bal",
"type": "amount"
},
{
"domain": "lx_debt",
"field": "uncle_credit_cart_lmt",
"field": "uncle_credit_card_lmt",
"type": "amount"
},
{
"domain": "lx_debt",
"field": "uncle_credit_cart_state",
"field": "uncle_credit_card_state",
"type": "status",
"options": ["正常", "逾期", "不良"]
},

View File

@@ -26,7 +26,7 @@ app = FastAPI(
- **解析状态** - 轮询检查文件解析状态
- **文件删除** - 批量删除上传的文件
- **流水查询** - 分页获取银行流水数据
- **征信解析** - 读取 HTML 远程地址并返回结构化征信 payload
- **征信解析** - 发起 HTML 远程解析并通过结果接口返回结构化征信 payload
### 错误模拟
@@ -40,7 +40,8 @@ app = FastAPI(
2. 上传文件: POST /watson/api/project/remoteUploadSplitFile
3. 轮询解析状态: POST /watson/api/project/upload/getpendings
4. 获取流水: POST /watson/api/project/getBSByLogId
5. 征信解析: POST /api/service/interface/invokeService/xfeature
5. 征信解析发起: POST /api/service/interface/invokeService/xfeature
6. 征信解析结果: POST /api/service/interface/invokeService/xfeatureResult
""",
version=settings.APP_VERSION,
docs_url="/docs",

View File

@@ -12,6 +12,7 @@ router = APIRouter()
payload_service = CreditPayloadService("config/credit_feature_schema.json")
debug_service = CreditDebugService("config/credit_response_examples.json")
identity_service = CreditHtmlIdentityService()
result_cache = {}
@router.post("/api/service/interface/invokeService/xfeature")
@@ -40,6 +41,27 @@ async def html_eval(
filename=remote_filename(remotePath),
subject_identity=subject_identity,
)
result_cache[serialNum] = payload
return debug_service.build_initiate_success_response(serialNum)
@router.post("/api/service/interface/invokeService/xfeatureResult")
async def html_eval_result(
serialNum: Optional[str] = Form(None),
orgCode: Optional[str] = Form(None),
runType: Optional[str] = Form(None),
):
error_response = debug_service.validate_result_request(
serial_num=serialNum,
org_code=orgCode,
run_type=runType,
)
if error_response:
return error_response
payload = result_cache.get(serialNum)
if payload is None:
return debug_service.build_result_not_found_response(serialNum)
return debug_service.build_success_response(payload)

View File

@@ -41,10 +41,51 @@ class CreditDebugService:
return self.build_error_response("ERR_10002")
return None
def validate_result_request(
self,
serial_num: Optional[str],
org_code: Optional[str],
run_type: 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")
return None
def build_initiate_success_response(self, serial_num: str) -> dict:
message = f"文件写入成功,流水号为: {serial_num}"
return {
"success": True,
"code": 10000,
"data": {
"mappingOutputFields": {
"message": message,
},
"reasonMessage": "Running successfully",
"outputFields": {
"C_S_ZXJXMESSAGE": message,
},
"procCode": "999000",
"bizId": serial_num,
"reasonCode": 200,
"status": 1,
},
}
def build_success_response(self, payload: dict) -> dict:
response = copy.deepcopy(self.templates["success"])
response["payload"] = payload
return self.wrap_mapping_response(response)
return self.wrap_mapping_response(response, status=1, reason_code=200)
def build_result_not_found_response(self, serial_num: str) -> dict:
return self.wrap_mapping_response({
"message": f"征信解析结果未返回: {serial_num}",
"payload": None,
"status_code": "ERR_99999",
}, status=0, reason_code=500, reason_message=f"征信解析结果未返回: {serial_num}")
def build_missing_param_response(self, param_name: str) -> dict:
response = self.build_error_response("ERR_99999")
@@ -53,7 +94,8 @@ class CreditDebugService:
return response
def build_error_response(self, error_code: str) -> dict:
return self.wrap_mapping_response(copy.deepcopy(self.templates["errors"][error_code]))
return self.wrap_mapping_response(copy.deepcopy(self.templates["errors"][error_code]),
status=0, reason_code=500)
def detect_error_marker(self, model: str) -> Optional[str]:
matched = re.search(r"error_(ERR_\d+)", model)
@@ -64,12 +106,21 @@ class CreditDebugService:
return error_code
return None
def wrap_mapping_response(self, mapping_output_fields: dict) -> dict:
def wrap_mapping_response(
self,
mapping_output_fields: dict,
status: int = 0,
reason_code: int = 500,
reason_message: Optional[str] = None,
) -> dict:
return {
"success": True,
"code": 10000,
"data": {
"mappingOutputFields": mapping_output_fields,
"reasonMessage": reason_message or mapping_output_fields.get("message"),
"reasonCode": reason_code,
"status": status,
},
}

View File

@@ -26,12 +26,16 @@ class FakeStaffIdentityRepository:
@pytest.fixture(autouse=True)
def reset_file_service_state():
"""避免 file_service 单例状态影响测试顺序。"""
from routers import credit_api
file_service.file_records.clear()
file_service.log_counter = settings.INITIAL_LOG_ID
file_service.staff_identity_repository = FakeStaffIdentityRepository()
credit_api.result_cache.clear()
yield
file_service.file_records.clear()
file_service.log_counter = settings.INITIAL_LOG_ID
credit_api.result_cache.clear()
@pytest.fixture
@@ -41,7 +45,7 @@ def client():
try:
from routers import credit_api
if not any(route.path == "/xfeature-mngs/conversation/htmlEval" for route in app.routes):
if not any(route.path == "/api/service/interface/invokeService/xfeature" for route in app.routes):
app.include_router(credit_api.router, tags=["征信解析接口"])
app.openapi_schema = None
except ModuleNotFoundError:

View File

@@ -1,47 +1,124 @@
def test_html_eval_should_return_credit_payload(client, sample_credit_html_file):
from routers import credit_api
def mapping_fields(response):
return response.json()["data"]["mappingOutputFields"]
def mock_remote_html(monkeypatch, sample_credit_html_file):
monkeypatch.setattr(credit_api, "fetch_remote_html", lambda remote_path: sample_credit_html_file[1])
def test_credit_parse_should_initiate_and_return_result(client, monkeypatch, sample_credit_html_file):
mock_remote_html(monkeypatch, sample_credit_html_file)
serial_num = "CCDI_CREDIT_TEST_001"
initiate_response = client.post(
"/api/service/interface/invokeService/xfeature",
data={
"serialNum": serial_num,
"orgCode": "999000",
"runType": "1",
"remotePath": "http://127.0.0.1:62318/profile/credit-html/sample.html",
"model": "LXCUSTALL",
},
)
assert initiate_response.status_code == 200
initiate_data = initiate_response.json()
assert initiate_data["success"] is True
assert initiate_data["code"] == 10000
assert initiate_data["data"]["status"] == 1
assert initiate_data["data"]["reasonCode"] == 200
assert "文件写入成功" in mapping_fields(initiate_response)["message"]
assert "payload" not in mapping_fields(initiate_response)
result_response = client.post(
"/api/service/interface/invokeService/xfeatureResult",
data={
"serialNum": serial_num,
"orgCode": "999000",
"runType": "1",
},
)
assert result_response.status_code == 200
result_data = result_response.json()
assert result_data["data"]["status"] == 1
assert result_data["data"]["reasonCode"] == 200
result_fields = mapping_fields(result_response)
assert result_fields["status_code"] == "0"
assert result_fields["message"] == "成功"
assert result_fields["payload"]["lx_header"]["query_cust_name"] == "测试员工"
assert result_fields["payload"]["lx_header"]["query_cert_no"] == "320101199001010030"
assert "uncle_credit_card_bal" in result_fields["payload"]["lx_debt"]
assert "uncle_credit_cart_bal" not in result_fields["payload"]["lx_debt"]
def test_credit_parse_should_return_err_99999_for_missing_model(client, monkeypatch, sample_credit_html_file):
mock_remote_html(monkeypatch, sample_credit_html_file)
response = client.post(
"/xfeature-mngs/conversation/htmlEval",
data={"model": "LXCUSTALL", "hType": "PERSON"},
files={"file": sample_credit_html_file},
"/api/service/interface/invokeService/xfeature",
data={
"serialNum": "CCDI_CREDIT_TEST_002",
"orgCode": "999000",
"runType": "1",
"remotePath": "http://127.0.0.1:62318/profile/credit-html/sample.html",
},
)
assert response.status_code == 200
data = response.json()
assert data["status_code"] == "0"
assert data["message"] == "成功"
assert "lx_header" in data["payload"]
assert data["payload"]["lx_header"]["query_cust_name"] == "测试员工"
assert data["payload"]["lx_header"]["query_cert_no"] == "320101199001010030"
assert response.json()["data"]["status"] == 0
assert response.json()["data"]["reasonCode"] == 500
assert mapping_fields(response)["status_code"] == "ERR_99999"
assert "model" in mapping_fields(response)["message"]
def test_html_eval_should_return_err_99999_for_missing_model(client, sample_credit_html_file):
def test_credit_parse_should_support_debug_error_marker(client, monkeypatch, sample_credit_html_file):
mock_remote_html(monkeypatch, sample_credit_html_file)
response = client.post(
"/xfeature-mngs/conversation/htmlEval",
data={"hType": "PERSON"},
files={"file": sample_credit_html_file},
"/api/service/interface/invokeService/xfeature",
data={
"serialNum": "CCDI_CREDIT_TEST_003",
"orgCode": "999000",
"runType": "1",
"remotePath": "http://127.0.0.1:62318/profile/credit-html/sample.html",
"model": "error_ERR_10001",
},
)
assert response.status_code == 200
assert response.json()["status_code"] == "ERR_99999"
assert mapping_fields(response)["status_code"] == "ERR_10001"
def test_html_eval_should_return_err_10003_for_invalid_h_type(client, sample_credit_html_file):
def test_credit_result_should_return_err_99999_for_unknown_serial_num(client):
response = client.post(
"/xfeature-mngs/conversation/htmlEval",
data={"model": "LXCUSTALL", "hType": "JSON"},
files={"file": sample_credit_html_file},
"/api/service/interface/invokeService/xfeatureResult",
data={
"serialNum": "CCDI_CREDIT_NOT_EXISTS",
"orgCode": "999000",
"runType": "1",
},
)
assert response.status_code == 200
assert response.json()["status_code"] == "ERR_10003"
result_fields = mapping_fields(response)
assert result_fields["status_code"] == "ERR_99999"
assert "征信解析结果未返回" in result_fields["message"]
def test_html_eval_should_support_debug_error_marker(client, sample_credit_html_file):
def test_credit_result_should_return_err_99999_for_missing_serial_num(client):
response = client.post(
"/xfeature-mngs/conversation/htmlEval",
data={"model": "error_ERR_10001", "hType": "PERSON"},
files={"file": sample_credit_html_file},
"/api/service/interface/invokeService/xfeatureResult",
data={
"orgCode": "999000",
"runType": "1",
},
)
assert response.status_code == 200
assert response.json()["status_code"] == "ERR_10001"
result_fields = mapping_fields(response)
assert result_fields["status_code"] == "ERR_99999"
assert "serialNum" in result_fields["message"]

View File

@@ -23,5 +23,6 @@ def test_dev_parse_args_should_reject_invalid_mode():
def test_app_should_register_credit_mock_routes():
paths = {route.path for route in app.routes}
assert "/xfeature-mngs/conversation/htmlEval" in paths
assert "/api/service/interface/invokeService/xfeature" in paths
assert "/api/service/interface/invokeService/xfeatureResult" in paths
assert "/credit/health" in paths

View File

@@ -150,7 +150,8 @@ lsfx:
credit-parse:
api:
url: http://localhost:8000/api/service/interface/invokeService/xfeature
result-url: http://localhost:8000/api/service/interface/invokeService/xfeatureResult
file-public-base-url: http://127.0.0.1:62318
org-code: 902000
org-code: 999000
run-type: 1
model: LXCUSTALL

View File

@@ -150,7 +150,8 @@ lsfx:
credit-parse:
api:
url: http://192.168.0.111:62320/api/service/interface/invokeService/xfeature
result-url: http://192.168.0.111:62320/api/service/interface/invokeService/xfeatureResult
file-public-base-url: http://192.168.0.111:62318
org-code: 902000
org-code: 999000
run-type: 1
model: LXCUSTALL

View File

@@ -145,6 +145,7 @@ lsfx:
credit-parse:
api:
url: http://64.202.32.40:8083/api/service/interface/invokeService/xfeature
result-url: http://64.202.32.40:8083/api/service/interface/invokeService/xfeatureResult
file-public-base-url: http://64.116.19.153
org-code: 999000
run-type: 1

View File

@@ -148,7 +148,8 @@ lsfx:
credit-parse:
api:
url: http://192.168.0.111:62320/api/service/interface/invokeService/xfeature
result-url: http://192.168.0.111:62320/api/service/interface/invokeService/xfeatureResult
file-public-base-url: http://158.234.199.250:62318
org-code: 902000
org-code: 999000
run-type: 1
model: LXCUSTALL