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 1591ca73..b6e556bd 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 @@ -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 requireHeader(CreditParsePayload payload) { Map header = payload.getLxHeader(); if (header == null || header.isEmpty()) { diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssembler.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssembler.java index 607c932a..1c55ddb6 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssembler.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssembler.java @@ -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 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 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 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(); } 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 117f5b8f..db3176f7 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 @@ -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", "a".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", "a".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; } diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssemblerTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssemblerTest.java index e0316190..746c733d 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssemblerTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/support/CreditInfoPayloadAssemblerTest.java @@ -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 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 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 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 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(); 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 238cd6e8..1c4bbcdc 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 @@ -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 params = new HashMap<>(); - params.put("serialNum", buildSerialNum()); - params.put("orgCode", orgCode); - params.put("runType", runType); - params.put("remotePath", remotePath); - params.put("model", actualModel); + Map 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 buildInitiateParams(String serialNum, String model, String remotePath) { + Map params = buildBaseParams(serialNum); + params.put("remotePath", remotePath); + params.put("model", model); + return params; + } + + private Map buildBaseParams(String serialNum) { + Map params = new HashMap<>(); + params.put("serialNum", serialNum); + params.put("orgCode", orgCode); + params.put("runType", runType); + return params; + } + + private CreditParseInvokeResponse queryResult(String serialNum) { + Map 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 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); 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 index d8d7826e..116f0abe 100644 --- 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 @@ -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; } 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 7c693421..8f167078 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 @@ -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.>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.>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> paramsCaptor = ArgumentCaptor.forClass((Class) Map.class); + ArgumentCaptor> initiateParamsCaptor = ArgumentCaptor.forClass((Class) Map.class); verify(httpUtil).postUrlEncodedFormForString( eq("http://tz/api/service/interface/invokeService/xfeature"), - paramsCaptor.capture(), + initiateParamsCaptor.capture(), + isNull() + ); + ArgumentCaptor> resultParamsCaptor = ArgumentCaptor.forClass((Class) Map.class); + verify(httpUtil).postUrlEncodedFormForString( + eq("http://tz/api/service/interface/invokeService/xfeatureResult"), + resultParamsCaptor.capture(), isNull() ); - 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")); + Map initiateParams = initiateParamsCaptor.getValue(); + Map 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.>any(), + isNull() + )).thenReturn(initiateSuccessResponse()); + when(httpUtil.postUrlEncodedFormForString( + eq("http://tz/api/service/interface/invokeService/xfeatureResult"), + org.mockito.ArgumentMatchers.>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.>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.>any(), + isNull() + )).thenReturn(initiateSuccessResponse()); + when(httpUtil.postUrlEncodedFormForString( + eq("http://tz/api/service/interface/invokeService/xfeatureResult"), + org.mockito.ArgumentMatchers.>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.>any(), + isNull() + )).thenReturn(initiateSuccessResponse()); + when(httpUtil.postUrlEncodedFormForString( + eq("http://tz/api/service/interface/invokeService/xfeatureResult"), + org.mockito.ArgumentMatchers.>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.>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.>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 sleepIntervals = new ArrayList<>(); + + @Override + protected void sleepBeforeNextResultQuery(long intervalMillis) { + sleepIntervals.add(intervalMillis); + } + + private List getSleepIntervals() { + return sleepIntervals; + } } } diff --git a/docs/design/2026-03-23-credit-info-maintenance-design.md b/docs/design/2026-03-23-credit-info-maintenance-design.md index 94dc5f23..2e4e89ea 100644 --- a/docs/design/2026-03-23-credit-info-maintenance-design.md +++ b/docs/design/2026-03-23-credit-info-maintenance-design.md @@ -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. 最新征信判定与覆盖策略 系统只保留员工最新征信,规则如下: diff --git a/docs/plans/backend/2026-05-14-credit-parse-result-polling-backend-plan.md b/docs/plans/backend/2026-05-14-credit-parse-result-polling-backend-plan.md new file mode 100644 index 00000000..4f2a4971 --- /dev/null +++ b/docs/plans/backend/2026-05-14-credit-parse-result-polling-backend-plan.md @@ -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` + diff --git a/docs/reports/implementation/2026-05-14-credit-parse-result-polling-implementation.md b/docs/reports/implementation/2026-05-14-credit-parse-result-polling-implementation.md new file mode 100644 index 00000000..be875dd4 --- /dev/null +++ b/docs/reports/implementation/2026-05-14-credit-parse-result-polling-implementation.md @@ -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 清理本地状态,因此未继续执行页面上传动作。 + - 已改用真实后端接口完成上传、落库、日志和清理闭环验证。 diff --git a/docs/reports/implementation/2026-05-15-credit-card-field-prefix-implementation.md b/docs/reports/implementation/2026-05-15-credit-card-field-prefix-implementation.md new file mode 100644 index 00000000..f007dea9 --- /dev/null +++ b/docs/reports/implementation/2026-05-15-credit-card-field-prefix-implementation.md @@ -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`:通过。 diff --git a/docs/reports/implementation/2026-05-15-credit-missing-sentinel-implementation.md b/docs/reports/implementation/2026-05-15-credit-missing-sentinel-implementation.md new file mode 100644 index 00000000..32f012e5 --- /dev/null +++ b/docs/reports/implementation/2026-05-15-credit-missing-sentinel-implementation.md @@ -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` 均按无对应风险处理。 diff --git a/docs/reports/implementation/2026-05-15-credit-parse-initiate-success-logic.md b/docs/reports/implementation/2026-05-15-credit-parse-initiate-success-logic.md new file mode 100644 index 00000000..6b8f4404 --- /dev/null +++ b/docs/reports/implementation/2026-05-15-credit-parse-initiate-success-logic.md @@ -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`:通过。 diff --git a/lsfx-mock-server/README.md b/lsfx-mock-server/README.md index 6998d557..1184d9af 100644 --- a/lsfx-mock-server/README.md +++ b/lsfx-mock-server/README.md @@ -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` | 征信解析健康检查 | ## ⚠️ 错误码列表 diff --git a/lsfx-mock-server/config/credit_feature_schema.json b/lsfx-mock-server/config/credit_feature_schema.json index a05c494b..01b5037c 100644 --- a/lsfx-mock-server/config/credit_feature_schema.json +++ b/lsfx-mock-server/config/credit_feature_schema.json @@ -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": ["正常", "逾期", "不良"] }, diff --git a/lsfx-mock-server/main.py b/lsfx-mock-server/main.py index 8d60a31e..d0a1cc7a 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,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", diff --git a/lsfx-mock-server/routers/credit_api.py b/lsfx-mock-server/routers/credit_api.py index 37b10b79..a42665a8 100644 --- a/lsfx-mock-server/routers/credit_api.py +++ b/lsfx-mock-server/routers/credit_api.py @@ -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) diff --git a/lsfx-mock-server/services/credit_debug_service.py b/lsfx-mock-server/services/credit_debug_service.py index 272135bb..82cf385f 100644 --- a/lsfx-mock-server/services/credit_debug_service.py +++ b/lsfx-mock-server/services/credit_debug_service.py @@ -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, }, } diff --git a/lsfx-mock-server/tests/conftest.py b/lsfx-mock-server/tests/conftest.py index 695d6b3b..b807c9bc 100644 --- a/lsfx-mock-server/tests/conftest.py +++ b/lsfx-mock-server/tests/conftest.py @@ -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: diff --git a/lsfx-mock-server/tests/test_credit_api.py b/lsfx-mock-server/tests/test_credit_api.py index 16ad1795..0df19edf 100644 --- a/lsfx-mock-server/tests/test_credit_api.py +++ b/lsfx-mock-server/tests/test_credit_api.py @@ -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"] diff --git a/lsfx-mock-server/tests/test_startup.py b/lsfx-mock-server/tests/test_startup.py index e40bb1a9..fc5abdc2 100644 --- a/lsfx-mock-server/tests/test_startup.py +++ b/lsfx-mock-server/tests/test_startup.py @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index d5811189..d8085cd1 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-nas.yml b/ruoyi-admin/src/main/resources/application-nas.yml index 67b2384b..cbc346e9 100644 --- a/ruoyi-admin/src/main/resources/application-nas.yml +++ b/ruoyi-admin/src/main/resources/application-nas.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-pro.yml b/ruoyi-admin/src/main/resources/application-pro.yml index 426f954f..143af674 100644 --- a/ruoyi-admin/src/main/resources/application-pro.yml +++ b/ruoyi-admin/src/main/resources/application-pro.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-uat.yml b/ruoyi-admin/src/main/resources/application-uat.yml index 9c9c1790..ab52f675 100644 --- a/ruoyi-admin/src/main/resources/application-uat.yml +++ b/ruoyi-admin/src/main/resources/application-uat.yml @@ -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