Compare commits
16 Commits
a40c5ce439
...
29be8a88a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 29be8a88a8 | |||
| 93527a977e | |||
| c7311f9319 | |||
| 7200b33542 | |||
| ccc294d599 | |||
| b195386467 | |||
| 50a8c4700a | |||
| 7e3e6d7923 | |||
| 1c02e4baea | |||
| f2dce3b70e | |||
| 89fb2ff2cc | |||
| 97b3783c53 | |||
| d7f34f009d | |||
| 397bd07e1c | |||
| 63d8904d01 | |||
| 15d17e4175 |
@@ -44,5 +44,11 @@
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ruoyi.lsfx.client;
|
||||
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import com.ruoyi.lsfx.util.HttpUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CreditParseClient {
|
||||
|
||||
@Resource
|
||||
private HttpUtil httpUtil;
|
||||
|
||||
@Value("${credit-parse.api.url}")
|
||||
private String creditParseUrl;
|
||||
|
||||
public CreditParseResponse parse(String model, String hType, File file) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("【征信解析】开始调用: fileName={}, model={}, hType={}", file.getName(), model, hType);
|
||||
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("model", model);
|
||||
params.put("hType", hType);
|
||||
params.put("file", file);
|
||||
|
||||
CreditParseResponse response = httpUtil.uploadFile(creditParseUrl, params, null, CreditParseResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("【征信解析】调用完成: statusCode={}, cost={}ms",
|
||||
response != null ? response.getStatusCode() : null, elapsed);
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
log.error("【征信解析】调用失败: fileName={}, model={}, hType={}, error={}",
|
||||
file.getName(), model, hType, e.getMessage(), e);
|
||||
throw new LsfxApiException("征信解析调用失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ruoyi.lsfx.controller;
|
||||
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.lsfx.client.CreditParseClient;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
@Tag(name = "征信解析接口测试", description = "用于测试征信解析接口")
|
||||
@Anonymous
|
||||
@RestController
|
||||
@RequestMapping("/lsfx/credit")
|
||||
public class CreditParseController {
|
||||
|
||||
private static final String DEFAULT_MODEL = "LXCUSTALL";
|
||||
private static final String DEFAULT_HTYPE = "PERSON";
|
||||
|
||||
@Resource
|
||||
private CreditParseClient creditParseClient;
|
||||
|
||||
@Operation(summary = "解析征信HTML", description = "上传征信HTML文件并调用外部解析服务")
|
||||
@PostMapping("/parse")
|
||||
public AjaxResult parse(@Parameter(description = "征信HTML文件") @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "解析模型,默认LXCUSTALL") @RequestParam(required = false) String model,
|
||||
@Parameter(description = "主体类型,默认PERSON") @RequestParam(required = false) String hType) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
return AjaxResult.error("征信HTML文件不能为空");
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (StringUtils.isBlank(originalFilename)) {
|
||||
return AjaxResult.error("文件名不能为空");
|
||||
}
|
||||
|
||||
String lowerCaseName = originalFilename.toLowerCase();
|
||||
if (!lowerCaseName.endsWith(".html") && !lowerCaseName.endsWith(".htm")) {
|
||||
return AjaxResult.error("仅支持 HTML 格式文件");
|
||||
}
|
||||
|
||||
String actualModel = StringUtils.isBlank(model) ? DEFAULT_MODEL : model;
|
||||
String actualHType = StringUtils.isBlank(hType) ? DEFAULT_HTYPE : hType;
|
||||
|
||||
Path tempFile = null;
|
||||
try {
|
||||
String suffix = lowerCaseName.endsWith(".htm") ? ".htm" : ".html";
|
||||
tempFile = Files.createTempFile("credit_parse_", suffix);
|
||||
Files.copy(file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
File convertedFile = tempFile.toFile();
|
||||
CreditParseResponse response = creditParseClient.parse(actualModel, actualHType, convertedFile);
|
||||
return AjaxResult.success(response);
|
||||
} catch (LsfxApiException e) {
|
||||
return AjaxResult.error(e.getMessage());
|
||||
} catch (IOException e) {
|
||||
return AjaxResult.error("文件转换失败:" + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
return AjaxResult.error("征信解析失败:" + e.getMessage());
|
||||
} finally {
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException ignored) {
|
||||
// 忽略临时文件删除失败,避免影响主流程返回
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ruoyi.lsfx.domain.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class CreditParsePayload {
|
||||
|
||||
@JsonProperty("lx_header")
|
||||
private Map<String, Object> lxHeader;
|
||||
|
||||
@JsonProperty("lx_debt")
|
||||
private Map<String, Object> lxDebt;
|
||||
|
||||
@JsonProperty("lx_publictype")
|
||||
private Map<String, Object> lxPublictype;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ruoyi.lsfx.domain.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreditParseResponse {
|
||||
|
||||
private String message;
|
||||
|
||||
@JsonProperty("status_code")
|
||||
private String statusCode;
|
||||
|
||||
private CreditParsePayload payload;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.ruoyi.lsfx.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import com.ruoyi.lsfx.util.HttpUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.argThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CreditParseClientTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock
|
||||
private HttpUtil httpUtil;
|
||||
|
||||
@InjectMocks
|
||||
private CreditParseClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(client, "creditParseUrl", "http://credit-host/xfeature-mngs/conversation/htmlEval");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeCreditParseResponse() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"message": "成功",
|
||||
"status_code": "0",
|
||||
"payload": {
|
||||
"lx_header": {"query_cert_no": "3301"},
|
||||
"lx_debt": {"uncle_bank_house_bal": "12.00"},
|
||||
"lx_publictype": {"civil_cnt": 1}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
CreditParseResponse response = objectMapper.readValue(json, CreditParseResponse.class);
|
||||
|
||||
assertEquals("0", response.getStatusCode());
|
||||
assertEquals("3301", response.getPayload().getLxHeader().get("query_cert_no"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCallConfiguredUrlWithMultipartParams() {
|
||||
File file = new File("sample.html");
|
||||
CreditParseResponse response = new CreditParseResponse();
|
||||
response.setStatusCode("0");
|
||||
|
||||
when(httpUtil.uploadFile(eq("http://credit-host/xfeature-mngs/conversation/htmlEval"), anyMap(), isNull(), eq(CreditParseResponse.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
CreditParseResponse actual = client.parse("LXCUSTALL", "PERSON", file);
|
||||
|
||||
assertEquals("0", actual.getStatusCode());
|
||||
verify(httpUtil).uploadFile(eq("http://credit-host/xfeature-mngs/conversation/htmlEval"), argThat(params ->
|
||||
"LXCUSTALL".equals(params.get("model"))
|
||||
&& "PERSON".equals(params.get("hType"))
|
||||
&& file.equals(params.get("file"))
|
||||
), isNull(), eq(CreditParseResponse.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldWrapHttpErrorsAsLsfxApiException() {
|
||||
when(httpUtil.uploadFile(anyString(), anyMap(), isNull(), eq(CreditParseResponse.class)))
|
||||
.thenThrow(new LsfxApiException("网络失败"));
|
||||
|
||||
assertThrows(LsfxApiException.class,
|
||||
() -> client.parse("LXCUSTALL", "PERSON", new File("sample.html")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ruoyi.lsfx.controller;
|
||||
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.lsfx.client.CreditParseClient;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CreditParseControllerTest {
|
||||
|
||||
@Mock
|
||||
private CreditParseClient client;
|
||||
|
||||
@InjectMocks
|
||||
private CreditParseController controller;
|
||||
|
||||
@Test
|
||||
void parse_shouldRejectEmptyFile() {
|
||||
AjaxResult result = controller.parse(null, null, null);
|
||||
assertEquals(500, result.get("code"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_shouldRejectNonHtmlFile() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "credit.pdf", "application/pdf", "x".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
AjaxResult result = controller.parse(file, null, null);
|
||||
assertEquals(500, result.get("code"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultModelAndTypeWhenMissing() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "credit.html", "text/html", "<html/>".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
CreditParseResponse response = new CreditParseResponse();
|
||||
response.setStatusCode("0");
|
||||
|
||||
when(client.parse(eq("LXCUSTALL"), eq("PERSON"), any(File.class))).thenReturn(response);
|
||||
|
||||
AjaxResult result = controller.parse(file, null, null);
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
assertSame(response, result.get("data"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnAjaxErrorWhenClientThrows() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "credit.html", "text/html", "<html/>".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
when(client.parse(anyString(), anyString(), any(File.class)))
|
||||
.thenThrow(new LsfxApiException("超时"));
|
||||
|
||||
AjaxResult result = controller.parse(file, null, null);
|
||||
|
||||
assertEquals(500, result.get("code"));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
NULL AS reasonDetail
|
||||
</sql>
|
||||
|
||||
<sql id="projectScopedDirectStaffSql">
|
||||
select distinct
|
||||
cast(staff.staff_id as char) as staffId,
|
||||
staff.id_card as idCard
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
|
||||
where bs.project_id = #{projectId}
|
||||
and bs.cret_no is not null
|
||||
and trim(bs.cret_no) != ''
|
||||
</sql>
|
||||
|
||||
<sql id="cashDepositPredicate">
|
||||
(
|
||||
(
|
||||
@@ -911,8 +922,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
pt.supplier_name AS supplierName,
|
||||
pt.actual_amount AS actualAmount
|
||||
from ccdi_purchase_transaction pt
|
||||
inner join ccdi_base_staff staff
|
||||
on CAST(staff.staff_id AS CHAR) = pt.applicant_id
|
||||
inner join (
|
||||
<include refid="projectScopedDirectStaffSql"/>
|
||||
) project_staff on project_staff.staffId = pt.applicant_id
|
||||
where IFNULL(pt.actual_amount, 0) > 100000
|
||||
union
|
||||
select distinct
|
||||
@@ -921,8 +933,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
pt.supplier_name AS supplierName,
|
||||
pt.actual_amount AS actualAmount
|
||||
from ccdi_purchase_transaction pt
|
||||
inner join ccdi_base_staff staff
|
||||
on CAST(staff.staff_id AS CHAR) = pt.purchase_leader_id
|
||||
inner join (
|
||||
<include refid="projectScopedDirectStaffSql"/>
|
||||
) project_staff on project_staff.staffId = pt.purchase_leader_id
|
||||
where pt.purchase_leader_id is not null
|
||||
and IFNULL(pt.actual_amount, 0) > 100000
|
||||
) t
|
||||
@@ -955,24 +968,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
SUM(source.actualAmount) / NULLIF(total_amount.totalAmount, 0) AS supplierRatio
|
||||
from (
|
||||
select distinct
|
||||
staff.id_card AS objectKey,
|
||||
project_staff.idCard AS objectKey,
|
||||
pt.purchase_id AS purchaseId,
|
||||
pt.supplier_name AS supplierName,
|
||||
IFNULL(pt.actual_amount, 0) AS actualAmount
|
||||
from ccdi_purchase_transaction pt
|
||||
inner join ccdi_base_staff staff on CAST(staff.staff_id AS CHAR) = pt.applicant_id
|
||||
inner join (
|
||||
<include refid="projectScopedDirectStaffSql"/>
|
||||
) project_staff on project_staff.staffId = pt.applicant_id
|
||||
where IFNULL(pt.actual_amount, 0) > 0
|
||||
and IFNULL(pt.supplier_name, '') <> ''
|
||||
|
||||
union
|
||||
|
||||
select distinct
|
||||
staff.id_card AS objectKey,
|
||||
project_staff.idCard AS objectKey,
|
||||
pt.purchase_id AS purchaseId,
|
||||
pt.supplier_name AS supplierName,
|
||||
IFNULL(pt.actual_amount, 0) AS actualAmount
|
||||
from ccdi_purchase_transaction pt
|
||||
inner join ccdi_base_staff staff on CAST(staff.staff_id AS CHAR) = pt.purchase_leader_id
|
||||
inner join (
|
||||
<include refid="projectScopedDirectStaffSql"/>
|
||||
) project_staff on project_staff.staffId = pt.purchase_leader_id
|
||||
where pt.purchase_leader_id is not null
|
||||
and IFNULL(pt.actual_amount, 0) > 0
|
||||
and IFNULL(pt.supplier_name, '') <> ''
|
||||
@@ -983,21 +1000,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
ROUND(SUM(source_total.actualAmount), 2) AS totalAmount
|
||||
from (
|
||||
select distinct
|
||||
staff.id_card AS objectKey,
|
||||
project_staff.idCard AS objectKey,
|
||||
pt.purchase_id AS purchaseId,
|
||||
IFNULL(pt.actual_amount, 0) AS actualAmount
|
||||
from ccdi_purchase_transaction pt
|
||||
inner join ccdi_base_staff staff on CAST(staff.staff_id AS CHAR) = pt.applicant_id
|
||||
inner join (
|
||||
<include refid="projectScopedDirectStaffSql"/>
|
||||
) project_staff on project_staff.staffId = pt.applicant_id
|
||||
where IFNULL(pt.actual_amount, 0) > 0
|
||||
|
||||
union
|
||||
|
||||
select distinct
|
||||
staff.id_card AS objectKey,
|
||||
project_staff.idCard AS objectKey,
|
||||
pt.purchase_id AS purchaseId,
|
||||
IFNULL(pt.actual_amount, 0) AS actualAmount
|
||||
from ccdi_purchase_transaction pt
|
||||
inner join ccdi_base_staff staff on CAST(staff.staff_id AS CHAR) = pt.purchase_leader_id
|
||||
inner join (
|
||||
<include refid="projectScopedDirectStaffSql"/>
|
||||
) project_staff on project_staff.staffId = pt.purchase_leader_id
|
||||
where pt.purchase_leader_id is not null
|
||||
and IFNULL(pt.actual_amount, 0) > 0
|
||||
) source_total
|
||||
|
||||
@@ -138,6 +138,16 @@ class CcdiBankTagAnalysisMapperXmlTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void purchaseRules_shouldBeScopedToCurrentProjectStaff() throws Exception {
|
||||
String xml = readXml(RESOURCE);
|
||||
|
||||
assertAll(
|
||||
() -> assertPurchaseRuleScopedByProject(xml, "selectLargePurchaseTransactionStatements"),
|
||||
() -> assertPurchaseRuleScopedByProject(xml, "selectSupplierConcentrationObjects")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void assetRegistrationMismatchRules_shouldUseRealSqlAndAssetTable() throws Exception {
|
||||
String xml = readXml(RESOURCE);
|
||||
@@ -192,4 +202,21 @@ class CcdiBankTagAnalysisMapperXmlTest {
|
||||
assertTrue(selectSql.contains("reasonDetail"), () -> selectId + " 缺少 reasonDetail");
|
||||
assertTrue(!selectSql.contains("where 1 = 0"), () -> selectId + " 仍是占位 SQL");
|
||||
}
|
||||
|
||||
private void assertPurchaseRuleScopedByProject(String xml, String selectId) {
|
||||
String selectSql = extractSelectSql(xml, selectId);
|
||||
String scopeSql = extractSqlFragment(xml, "projectScopedDirectStaffSql");
|
||||
assertTrue(selectSql.contains("projectScopedDirectStaffSql"), () -> selectId + " 未引用项目员工范围片段");
|
||||
assertTrue(scopeSql.contains("ccdi_bank_statement"), () -> selectId + " 缺少项目流水范围约束");
|
||||
assertTrue(scopeSql.contains("#{projectId}"), () -> selectId + " 缺少 projectId 过滤条件");
|
||||
}
|
||||
|
||||
private String extractSqlFragment(String xml, String sqlId) {
|
||||
Pattern pattern = Pattern.compile(
|
||||
"<sql\\s+id=\"" + sqlId + "\"[\\s\\S]*?</sql>"
|
||||
);
|
||||
Matcher matcher = pattern.matcher(xml);
|
||||
assertTrue(matcher.find(), () -> "未找到 sql 片段: " + sqlId);
|
||||
return matcher.group();
|
||||
}
|
||||
}
|
||||
|
||||
364
docs/design/2026-03-23-credit-parse-client-design.md
Normal file
364
docs/design/2026-03-23-credit-parse-client-design.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 征信解析客户端与接口设计方案
|
||||
|
||||
## 1. 背景
|
||||
|
||||
本次需求来自以下两份输入材料:
|
||||
|
||||
- `assets/征信解析/HTML引擎服务_ 接口设计说明书_1.docx`
|
||||
- `assets/征信解析/征信解析接口payload.xlsx`
|
||||
|
||||
根据说明书,本次目标外部接口为 `POST /xfeature-mngs/conversation/htmlEval`,请求格式为 `multipart/form-data`,核心入参为:
|
||||
|
||||
- `model`
|
||||
- `file`
|
||||
- `hType`
|
||||
|
||||
返回体结构为:
|
||||
|
||||
- `message`
|
||||
- `status_code`
|
||||
- `payload`
|
||||
|
||||
根据 Excel,`payload` 当前至少包含以下三个主题域对象:
|
||||
|
||||
- `lx_header`
|
||||
- `lx_debt`
|
||||
- `lx_publictype`
|
||||
|
||||
当前仓库中已有银行流水分析平台的集成代码,集中在 `ccdi-lsfx` 模块;但征信解析服务与流水分析平台不是同一套接口语义和配置边界。本次目标是在不破坏现有 `lsfx` 链路的前提下,新增一套独立的征信解析调用能力,并在 `ccdi-lsfx` 中暴露一个可供联调使用的后端接口。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 在 `ccdi-lsfx` 模块中新增独立的征信解析 `Client`。
|
||||
- 在 `ccdi-lsfx` 模块中新增对外联调接口,用于接收 HTML 文件并调用征信解析服务。
|
||||
- 征信解析配置与现有 `lsfx` 配置完全隔离,不共用同一配置前缀。
|
||||
- 配置项使用单一完整 URL,不拆分 `base-url` 和 `endpoints`。
|
||||
- 外部服务成功响应时,保留 `message`、`status_code`、`payload` 的原始语义。
|
||||
- 控制器仅做最小参数校验,不额外引入项目上传记录、异步任务、状态轮询或结果落库。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不接入 `ccdi-project` 现有项目文件上传主链路。
|
||||
- 不新增征信解析结果落库逻辑。
|
||||
- 不补充前端页面接入。
|
||||
- 不把征信解析 `Client` 并入现有 `LsfxAnalysisClient`。
|
||||
- 不将征信解析配置挂到 `lsfx.api.*` 下。
|
||||
- 不额外设计说明书之外的兜底流程、降级流程或兼容协议。
|
||||
|
||||
## 4. 方案对比
|
||||
|
||||
### 4.1 方案 A:直接扩展现有 `LsfxAnalysisClient`
|
||||
|
||||
把征信解析方法直接加到现有 `LsfxAnalysisClient`,并继续使用 `lsfx.api.*` 配置。
|
||||
|
||||
优点:
|
||||
|
||||
- 改动文件数最少。
|
||||
|
||||
缺点:
|
||||
|
||||
- 语义上把两套不同外部服务混在同一个 Client 中。
|
||||
- 配置边界不清晰,后续切换环境容易误改现有流水分析配置。
|
||||
- 不符合本次“新增 Client,并在配置文件里与 lsfx 分开”的要求。
|
||||
|
||||
### 4.2 方案 B:在 `ccdi-lsfx` 中新增独立 `CreditParseClient`
|
||||
|
||||
在 `ccdi-lsfx` 中新增独立控制器和独立 `Client`,并使用新的配置前缀承载完整 URL。
|
||||
|
||||
优点:
|
||||
|
||||
- 满足“接口加在 `ccdi-lsfx` 中”的要求。
|
||||
- 满足“新增 Client,在配置文件里与 lsfx 分开”的要求。
|
||||
- 与现有银行流水分析集成边界清晰,不会污染主链路。
|
||||
- 仍然复用现有 `HttpUtil`,实现路径最短。
|
||||
|
||||
缺点:
|
||||
|
||||
- 需要新增少量类和配置项。
|
||||
|
||||
### 4.3 方案 C:在 `ccdi-project` 中直接暴露业务接口并自行发起调用
|
||||
|
||||
由 `ccdi-project` 新增控制器和 service,直接使用 `RestTemplate` 调征信解析平台。
|
||||
|
||||
优点:
|
||||
|
||||
- 对业务模块看起来更直接。
|
||||
|
||||
缺点:
|
||||
|
||||
- 与用户已确认的“接口加在 `ccdi-lsfx` 中”不一致。
|
||||
- 会把外部平台调用能力分散到多个模块。
|
||||
- 后续复用和维护成本更高。
|
||||
|
||||
## 5. 推荐方案
|
||||
|
||||
采用方案 B:在 `ccdi-lsfx` 中新增独立 `CreditParseClient`、独立配置和独立联调接口。
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 与已确认需求完全一致。
|
||||
- 保持最短路径实现,不引入无关业务改造。
|
||||
- 外部服务边界清晰,后续如果还要补企业征信或更多解析接口,可以继续沿用这一结构。
|
||||
|
||||
## 6. 模块边界与目录设计
|
||||
|
||||
本次改动集中在 `ccdi-lsfx` 模块。
|
||||
|
||||
建议落点如下:
|
||||
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java`
|
||||
- `ruoyi-admin/src/main/resources/application-dev.yml`
|
||||
- `ruoyi-admin/src/main/resources/application-nas.yml`
|
||||
|
||||
职责划分如下:
|
||||
|
||||
- `CreditParseController`
|
||||
- 接收联调方上传的 HTML 文件和可选参数。
|
||||
- 做最小参数校验。
|
||||
- 将 `MultipartFile` 转换为临时文件。
|
||||
- 调用 `CreditParseClient`。
|
||||
- 返回 `AjaxResult`。
|
||||
|
||||
- `CreditParseClient`
|
||||
- 读取独立配置中的完整 URL。
|
||||
- 组装 `multipart/form-data` 参数。
|
||||
- 调用现有 `HttpUtil.uploadFile(...)`。
|
||||
- 统一记录调用日志和抛出平台调用异常。
|
||||
|
||||
- `CreditParseResponse`
|
||||
- 映射外部接口返回的 `message`、`status_code`、`payload`。
|
||||
|
||||
- `CreditParsePayload`
|
||||
- 承载 `lx_header`、`lx_debt`、`lx_publictype` 三个主题域对象。
|
||||
|
||||
## 7. 配置设计
|
||||
|
||||
征信解析配置独立于 `lsfx`,使用新的配置前缀。
|
||||
|
||||
建议配置结构如下:
|
||||
|
||||
```yml
|
||||
credit-parse:
|
||||
api:
|
||||
url: http://64.202.94.120:8081/xfeature-mngs/conversation/htmlEval
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `url` 保存单一完整地址,不拆分为 `base-url` 和 `endpoints`。
|
||||
- 不复用 `lsfx.api.connection-timeout`、`lsfx.api.read-timeout` 等配置项。
|
||||
- 本次实现优先沿用现有 `RestTemplate` Bean;若后续确需独立超时配置,再单独扩展专用 HTTP 配置,不在本次设计中预埋。
|
||||
|
||||
## 8. 对外接口设计
|
||||
|
||||
### 8.1 接口定义
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/lsfx/credit/parse`
|
||||
- Content-Type:`multipart/form-data`
|
||||
|
||||
接口放在 `ccdi-lsfx` 模块中,作为征信解析联调入口。
|
||||
|
||||
### 8.2 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------ | ---- | ---- | ------ | ---- |
|
||||
| `file` | `MultipartFile` | 是 | 无 | 征信 HTML 文件 |
|
||||
| `model` | `String` | 否 | `LXCUSTALL` | 主题域编码 |
|
||||
| `hType` | `String` | 否 | `PERSON` | 报文类型 |
|
||||
|
||||
### 8.3 本地校验规则
|
||||
|
||||
控制器层只做最小必要校验:
|
||||
|
||||
- `file` 不能为空。
|
||||
- 文件名不能为空。
|
||||
- 文件后缀必须为 `.html` 或 `.htm`。
|
||||
- `model` 为空时补默认值 `LXCUSTALL`。
|
||||
- `hType` 为空时补默认值 `PERSON`。
|
||||
|
||||
不在控制器层重复实现外部平台业务码校验,不提前限制:
|
||||
|
||||
- `model` 是否只能取 `LXCUSTALL`
|
||||
- `hType` 是否只能取 `PERSON` 或 `ENTERPRISE`
|
||||
|
||||
上述业务校验结果以外部服务返回的 `status_code` 为准。
|
||||
|
||||
### 8.4 返回规则
|
||||
|
||||
返回采用若依标准 `AjaxResult` 包装,规则如下:
|
||||
|
||||
- 本地参数校验失败:返回 `AjaxResult.error(...)`
|
||||
- 外部调用成功拿到响应:返回 `AjaxResult.success(responseBody)`
|
||||
- 网络异常、超时、反序列化异常或其他调用异常:返回 `AjaxResult.error("征信解析调用失败:" + e.getMessage())`
|
||||
|
||||
其中 `AjaxResult.data` 中保留外部原始业务语义,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"message": "成功",
|
||||
"status_code": "0",
|
||||
"payload": {
|
||||
"lx_header": {},
|
||||
"lx_debt": {},
|
||||
"lx_publictype": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这意味着:
|
||||
|
||||
- 外部业务失败码如 `ERR_99999`、`ERR_10002` 仍然作为正常 HTTP 调用结果返回。
|
||||
- 调用方根据 `data.status_code` 判断业务成功与否。
|
||||
- 本地接口不对外部业务码做二次翻译,避免语义偏移。
|
||||
|
||||
## 9. Client 设计
|
||||
|
||||
### 9.1 调用链
|
||||
|
||||
调用链固定为:
|
||||
|
||||
`CreditParseController` -> `CreditParseClient` -> `HttpUtil.uploadFile(...)` -> 征信解析服务
|
||||
|
||||
### 9.2 请求组装
|
||||
|
||||
`CreditParseClient` 发送 `multipart/form-data`,参数仅包含:
|
||||
|
||||
- `model`
|
||||
- `file`
|
||||
- `hType`
|
||||
|
||||
不新增说明书之外的扩展字段。
|
||||
|
||||
### 9.3 URL 读取
|
||||
|
||||
`CreditParseClient` 直接读取:
|
||||
|
||||
- `credit-parse.api.url`
|
||||
|
||||
不做 URL 拼接。
|
||||
|
||||
### 9.4 日志
|
||||
|
||||
日志风格保持与 `ccdi-lsfx` 现有习惯一致,至少包含:
|
||||
|
||||
- 请求开始:文件名、`model`、`hType`
|
||||
- 请求结束:耗时、返回 `status_code`
|
||||
- 请求失败:错误信息和异常堆栈
|
||||
|
||||
### 9.5 异常
|
||||
|
||||
`CreditParseClient` 统一抛出 `LsfxApiException` 风格异常,保持 `ccdi-lsfx` 模块内部异常语义一致。
|
||||
|
||||
## 10. 响应对象设计
|
||||
|
||||
### 10.1 请求 DTO
|
||||
|
||||
本次不新增请求 DTO。
|
||||
|
||||
原因:
|
||||
|
||||
- 控制器直接接收 `MultipartFile file, String model, String hType` 即可。
|
||||
- `Client` 内部直接构造 `Map<String, Object>` 最短路径实现。
|
||||
- 纯透传场景下,引入请求 DTO 只会增加样板代码。
|
||||
|
||||
### 10.2 响应 DTO
|
||||
|
||||
建议新增以下响应对象:
|
||||
|
||||
#### `CreditParseResponse`
|
||||
|
||||
- `String message`
|
||||
- `String statusCode`
|
||||
- `CreditParsePayload payload`
|
||||
|
||||
其中 `statusCode` 使用注解映射 JSON 字段 `status_code`。
|
||||
|
||||
#### `CreditParsePayload`
|
||||
|
||||
- `Map<String, Object> lxHeader`
|
||||
- `Map<String, Object> lxDebt`
|
||||
- `Map<String, Object> lxPublictype`
|
||||
|
||||
其中字段名使用注解映射:
|
||||
|
||||
- `lx_header`
|
||||
- `lx_debt`
|
||||
- `lx_publictype`
|
||||
|
||||
### 10.3 为何不展开成 30+ 强类型字段
|
||||
|
||||
本次目标是“添加调用征信解析接口的方法”,不是“建立完整征信指标领域模型”。因此本次不把 Excel 中的 30 余个字段全部定义为强类型 Java 属性,原因如下:
|
||||
|
||||
- 当前需求核心是打通调用,不是做复杂业务计算。
|
||||
- 外部字段后续仍可能调整,强类型化会增加维护成本。
|
||||
- 三段 `Map<String, Object>` 已足够支撑联调与响应透传。
|
||||
|
||||
后续如果业务明确需要对单个字段做服务内计算或落库,再单独收敛更细的领域对象。
|
||||
|
||||
## 11. 文件处理设计
|
||||
|
||||
由于现有 `HttpUtil.uploadFile(...)` 接收的是 `File` 对象,控制器层需要把上传的 `MultipartFile` 转为临时文件。
|
||||
|
||||
处理规则如下:
|
||||
|
||||
- 使用系统临时目录创建临时文件。
|
||||
- 临时文件名保留原始后缀,方便外部服务识别文件类型。
|
||||
- `Client` 调用结束后,在 `finally` 中删除临时文件。
|
||||
- 临时文件删除失败只记录日志,不覆盖主流程结果。
|
||||
|
||||
该处理仅用于本次同步调用,不沉淀到项目上传目录,不复用 `ccdi-project` 的文件上传记录表。
|
||||
|
||||
## 12. 测试设计
|
||||
|
||||
本次测试只覆盖后端最小闭环,不扩展到前端。
|
||||
|
||||
建议至少覆盖以下场景:
|
||||
|
||||
1. 合法 HTML 文件上传,外部服务返回 `status_code = 0`
|
||||
2. 文件为空,本地接口直接返回参数错误
|
||||
3. 文件后缀非法,本地接口直接返回参数错误
|
||||
4. 外部服务不可达或超时,接口返回调用失败信息
|
||||
5. 外部服务返回业务失败码,例如 `ERR_99999`,本地接口仍然按成功调用返回 `AjaxResult.success(...)`
|
||||
|
||||
如果测试过程中需要临时启动后端进程,测试完成后必须主动关闭,避免残留进程占用端口。
|
||||
|
||||
## 13. 实施边界确认
|
||||
|
||||
本次实施只包含以下内容:
|
||||
|
||||
- 新增 `CreditParseClient`
|
||||
- 新增 `CreditParseController`
|
||||
- 新增征信解析响应对象
|
||||
- 新增独立配置项 `credit-parse.api.url`
|
||||
- 补充后端测试
|
||||
|
||||
本次明确不包含:
|
||||
|
||||
- `ccdi-project` 主上传链路改造
|
||||
- 项目上传记录表扩展
|
||||
- 征信结果落库
|
||||
- 前端接入
|
||||
- 额外的兼容性补丁或兜底方案
|
||||
|
||||
## 14. 风险与约束
|
||||
|
||||
- 外部服务返回结构以说明书和联调实际为准;若实际返回字段名与说明书不一致,需要在实现阶段按真实返回修正映射。
|
||||
- 本次沿用现有 `RestTemplate` Bean;若后续征信解析服务需要独立连接池或独立超时,再单独设计,不在本次范围内扩展。
|
||||
- 由于本次不做落库,调用结果仅用于联调和即时查看。
|
||||
|
||||
## 15. 预期交付物
|
||||
|
||||
- 一份后端设计实现代码,落在 `ccdi-lsfx` 模块
|
||||
- 一份后端实施计划文档,落在 `docs/plans/backend/`
|
||||
- 一份前端实施计划文档,落在 `docs/plans/frontend/`,明确本次无前端实施项
|
||||
- 一份本次改动实施记录文档,落在 `docs/reports/implementation/`
|
||||
|
||||
本方案采用“`ccdi-lsfx` 内新增独立征信解析 Client 与联调接口、配置与 `lsfx` 完全隔离”的方式,以最短路径补齐征信解析调用能力,同时保证与现有银行流水分析链路边界清晰,符合当前需求边界。
|
||||
295
docs/design/2026-03-23-credit-parsing-mock-server-design.md
Normal file
295
docs/design/2026-03-23-credit-parsing-mock-server-design.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# 征信解析 Mock Server 设计方案
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前仓库已经有一个基于 FastAPI 的 [lsfx-mock-server](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server),用于模拟流水分析平台接口。本次新增需求来自以下两份输入材料:
|
||||
|
||||
- `assets/征信解析/征信解析接口payload.xlsx`
|
||||
- `assets/征信解析/HTML引擎服务_ 接口设计说明书_1.docx`
|
||||
|
||||
根据说明书,本次目标接口为 `POST /xfeature-mngs/conversation/htmlEval`,请求格式为 `form-data`,核心入参为 `model`、`file`、`hType`,返回结构为 `message`、`status_code`、`payload`。根据 Excel,本次 `payload` 至少覆盖 30 个征信指标字段,分布在 `lx_header`、`lx_debt`、`lx_publictype` 三个主题域对象下。
|
||||
|
||||
本次工作目标不是实现真实 HTML 解析能力,而是在现有 Mock 服务中补齐一个“结构正确、返回稳定、便于联调”的征信解析接口。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 在现有 [lsfx-mock-server](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server) 中新增征信解析 Mock 能力。
|
||||
- 实现说明书中的单一核心接口 `POST /xfeature-mngs/conversation/htmlEval`。
|
||||
- 支持 `model`、`file`、`hType` 的 `form-data` 请求。
|
||||
- 成功返回时输出与 Excel 字段定义一致的 `payload` 结构。
|
||||
- 按 `model + hType + 文件名` 生成稳定随机结果,保证同一请求重复调用结果一致。
|
||||
- 提供最小调试能力,包括健康检查接口和按参数触发失败码。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不解析真实 HTML 内容,不从文件正文抽取字段。
|
||||
- 不新增说明书之外的业务接口。
|
||||
- 不为每个字段单独编写硬编码生成逻辑。
|
||||
- 不引入独立新项目或第二套运行框架。
|
||||
- 不设计复杂的动态配置平台或可视化管理后台。
|
||||
|
||||
## 4. 方案对比
|
||||
|
||||
### 4.1 方案 A:最短硬编码
|
||||
|
||||
直接在现有路由文件中追加征信接口,并把随机生成逻辑写在单个函数或单个 service 内。
|
||||
|
||||
优点:
|
||||
|
||||
- 实现最快。
|
||||
|
||||
缺点:
|
||||
|
||||
- 路由层会掺杂业务规则。
|
||||
- 后续补错误码、字段规则、样例模板时可维护性差。
|
||||
|
||||
### 4.2 方案 B:模块化扩展
|
||||
|
||||
在现有 [lsfx-mock-server](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server) 中新增独立的征信解析路由、service 和配置文件,复用当前 FastAPI 应用和启动方式。
|
||||
|
||||
优点:
|
||||
|
||||
- 保持实现路径最短。
|
||||
- 复用现有工程基础设施和依赖。
|
||||
- 征信解析逻辑与现有 LSFX Mock 逻辑隔离,后续扩展清晰。
|
||||
|
||||
缺点:
|
||||
|
||||
- 需要增加少量模块文件和配置文件。
|
||||
|
||||
### 4.3 方案 C:全配置驱动
|
||||
|
||||
把 Excel 字段直接转换为运行时元数据,服务根据配置动态生成字段、校验和示例响应。
|
||||
|
||||
优点:
|
||||
|
||||
- 后续字段调整灵活。
|
||||
|
||||
缺点:
|
||||
|
||||
- 引入额外抽象,超出本次“最短路径实现”的边界。
|
||||
|
||||
## 5. 推荐方案
|
||||
|
||||
采用方案 B。
|
||||
|
||||
原因如下:
|
||||
|
||||
- 与用户确认的边界一致:直接扩展现有工程,不新建独立服务。
|
||||
- 可以在不影响已有 LSFX Mock 链路的前提下补齐征信解析能力。
|
||||
- 代码结构清晰,后续如果字段扩充或错误码增加,仍可局部修改。
|
||||
|
||||
## 6. 模块边界与目录设计
|
||||
|
||||
征信解析能力直接并入现有 [lsfx-mock-server](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server),但采用独立模块组织,避免与现有银行流水 Mock 逻辑混杂。
|
||||
|
||||
建议落点如下:
|
||||
|
||||
- `lsfx-mock-server/routers/credit_api.py`
|
||||
- `lsfx-mock-server/services/credit_payload_service.py`
|
||||
- `lsfx-mock-server/services/credit_debug_service.py`
|
||||
- `lsfx-mock-server/config/credit_feature_schema.json`
|
||||
- `lsfx-mock-server/config/credit_response_examples.json`
|
||||
|
||||
职责划分如下:
|
||||
|
||||
- `credit_api.py`:负责 HTTP 路由、`form-data` 入参接收、响应结构封装。
|
||||
- `credit_payload_service.py`:负责稳定随机种子生成、字段分组、payload 生成。
|
||||
- `credit_debug_service.py`:负责调试标记识别、错误码映射、健康检查响应。
|
||||
- `credit_feature_schema.json`:记录字段归属、类型、枚举范围等元数据。
|
||||
- `credit_response_examples.json`:保存成功和失败响应模板,保证结构统一。
|
||||
|
||||
## 7. 接口设计
|
||||
|
||||
### 7.1 接口定义
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/xfeature-mngs/conversation/htmlEval`
|
||||
- Content-Type:`multipart/form-data`
|
||||
|
||||
### 7.2 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---- | ---- | ---- |
|
||||
| `model` | `String` | 是 | 查询主题域,当前只接受 `LXCUSTALL` |
|
||||
| `file` | `File` | 是 | HTML 文件,仅校验上传存在 |
|
||||
| `hType` | `String` | 是 | 报文类型,只接受 `PERSON` 或 `ENTERPRISE` |
|
||||
|
||||
说明:接口说明书的参数表明确 `hType` 当前支持 `PERSON/ENTERPRISE`,但错误码表中 `ERR_10003` 的文案写为“报文类型无效,仅支持JSON/XML”。本设计将该差异视为原始文档歧义,校验逻辑以参数表为准,失败时的返回码与返回信息沿用错误码表定义。
|
||||
|
||||
### 7.3 成功响应
|
||||
|
||||
成功时统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "成功",
|
||||
"payload": {
|
||||
"lx_header": {},
|
||||
"lx_debt": {},
|
||||
"lx_publictype": {}
|
||||
},
|
||||
"status_code": "0"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 失败响应
|
||||
|
||||
失败时统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "关键参数缺失,参数名: model",
|
||||
"payload": null,
|
||||
"status_code": "ERR_99999"
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Payload 生成设计
|
||||
|
||||
### 8.1 生成原则
|
||||
|
||||
- 不解析真实 HTML 文件内容。
|
||||
- 仅基于 `model + hType + 文件名` 计算稳定随机种子。
|
||||
- 同一组输入每次调用返回相同结果。
|
||||
- 不同 `model`、`hType` 或文件名组合返回不同结果。
|
||||
|
||||
### 8.2 字段分组
|
||||
|
||||
根据 Excel,本次最小字段集合如下:
|
||||
|
||||
- `lx_header`
|
||||
- `query_cert_no`
|
||||
- `query_cust_name`
|
||||
- `report_time`
|
||||
- `lx_debt`
|
||||
- 房贷、车贷、经营贷、消费贷、其他贷款、非银行、信用卡等余额/金额/状态字段
|
||||
- `lx_publictype`
|
||||
- 民事判决、强制执行、行政处罚的记录数和金额字段
|
||||
|
||||
### 8.3 类型规则
|
||||
|
||||
为避免 30 个字段写成 30 段硬编码分支,schema 只保留最小类型抽象:
|
||||
|
||||
- `string`
|
||||
- `amount`
|
||||
- `count`
|
||||
- `status`
|
||||
|
||||
生成规则如下:
|
||||
|
||||
- `string`:生成符合业务格式的字符串。
|
||||
- `amount`:生成非负金额字符串,保留两位小数。
|
||||
- `count`:生成非负整数。
|
||||
- `status`:严格从 schema 枚举中选值,当前为 `正常`、`逾期`、`不良`。
|
||||
|
||||
### 8.4 头信息字段规则
|
||||
|
||||
虽然不解析真实 HTML,头信息字段仍需保证格式可用:
|
||||
|
||||
- `query_cert_no`:生成合法格式的证件号码样式字符串。
|
||||
- `query_cust_name`:生成脱敏风格或常见中文姓名样式字符串。
|
||||
- `report_time`:生成合法日期字符串,例如 `YYYY-MM-DD`。
|
||||
|
||||
## 9. 错误处理设计
|
||||
|
||||
错误处理只覆盖说明书已明确的最小集合,不新增自定义协议。
|
||||
|
||||
| 场景 | 返回码 | 返回信息 |
|
||||
| ---- | ------ | -------- |
|
||||
| 缺少 `model`、`file`、`hType` | `ERR_99999` | `关键参数缺失,参数名: XX` |
|
||||
| `model` 不为 `LXCUSTALL` | `ERR_10002` | `无效的主题域` |
|
||||
| `hType` 不为 `PERSON` 或 `ENTERPRISE` | `ERR_10003` | `报文类型无效,仅支持JSON/XML` |
|
||||
| 调试标记触发无效证件号类错误 | `ERR_10001` | `无效的证件号码` |
|
||||
| 调试标记触发机构号错误 | `ERR_10004` | `无效机构号或行社号` |
|
||||
| 调试标记触发权限错误 | `ERR_10005` | `无权限访问` |
|
||||
| 调试标记触发服务异常 | `ERR_10006` | `尽调报告生成异常:异步事件发送失败` |
|
||||
|
||||
调试标记建议复用现有 Mock 服务习惯,通过 `model` 中包含类似 `error_ERR_10001` 的值触发对应错误响应。
|
||||
|
||||
## 10. 调试能力设计
|
||||
|
||||
本次只提供最小调试能力:
|
||||
|
||||
- 提供健康检查接口,例如 `GET /credit/health`。
|
||||
- 支持通过特定 `model` 值触发失败码。
|
||||
- 不额外增加复杂调试参数。
|
||||
- 不支持运行时在线修改字段配置。
|
||||
|
||||
这样既能满足联调和测试,又不会偏离主业务接口行为。
|
||||
|
||||
## 11. 配置组织设计
|
||||
|
||||
Excel 文件只作为需求输入,不作为运行时依赖。服务运行时改为读取项目内的 JSON schema。
|
||||
|
||||
`credit_feature_schema.json` 建议字段结构如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"domain": "lx_header",
|
||||
"field": "query_cert_no",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_house_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 扩充字段时只需要补 schema。
|
||||
- 生成逻辑聚焦类型,不直接绑定具体字段数量。
|
||||
- 响应结构仍保持说明书和 Excel 要求。
|
||||
|
||||
## 12. 测试与验收设计
|
||||
|
||||
测试边界保持最短闭环:
|
||||
|
||||
- 单元测试
|
||||
- 成功响应结构正确。
|
||||
- 同一输入返回稳定随机一致结果。
|
||||
- 不同输入返回不同随机结果。
|
||||
- 调试标记能返回预期错误码。
|
||||
- 接口测试
|
||||
- `multipart/form-data` 上传可被正确接收。
|
||||
- 缺参时返回说明书约定格式。
|
||||
- 健康检查接口返回可用状态。
|
||||
|
||||
验收重点如下:
|
||||
|
||||
- 调用方按说明书格式发起请求时,能拿到合法 JSON 响应。
|
||||
- `payload` 字段结构与 Excel 保持一致。
|
||||
- 同一组请求参数重复调用时结果一致。
|
||||
- 参数异常时返回约定错误码与错误信息。
|
||||
|
||||
## 13. 文档与实施计划要求
|
||||
|
||||
按仓库约定,设计确认后需要继续产出两份实施计划:
|
||||
|
||||
- 后端实施计划:`docs/plans/backend/`
|
||||
- 前端实施计划:`docs/plans/frontend/`
|
||||
|
||||
由于本次主体是 Mock 服务开发,前端计划可聚焦联调接入、调试入口或调用说明,不扩展无关页面开发。
|
||||
|
||||
后续进入实现阶段后,还需补充本次改动的实施记录到 `docs/reports/implementation/`。
|
||||
|
||||
## 14. 实现落点
|
||||
|
||||
预计涉及如下文件范围:
|
||||
|
||||
- 新增 `lsfx-mock-server/routers/credit_api.py`
|
||||
- 新增 `lsfx-mock-server/services/credit_payload_service.py`
|
||||
- 新增 `lsfx-mock-server/services/credit_debug_service.py`
|
||||
- 新增 `lsfx-mock-server/config/credit_feature_schema.json`
|
||||
- 新增 `lsfx-mock-server/config/credit_response_examples.json`
|
||||
- 按需修改 `lsfx-mock-server/main.py` 或路由注册入口
|
||||
- 新增对应测试文件
|
||||
|
||||
## 15. 结论
|
||||
|
||||
本方案采用“在现有 Mock 服务中模块化扩展征信解析能力”的方式,以单接口、稳定随机、最小调试能力为边界完成征信解析 Mock Server 建设。该方案满足当前联调目标,同时避免引入真实 HTML 解析和过度设计,符合最短路径实现要求。
|
||||
@@ -0,0 +1,554 @@
|
||||
# Credit Parse Client Backend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 `ccdi-lsfx` 模块中新增独立征信解析 `Client` 和联调接口 `POST /lsfx/credit/parse`,通过独立配置调用外部征信解析服务。
|
||||
|
||||
**Architecture:** 复用 `ccdi-lsfx` 现有 `HttpUtil` 与异常体系,但不复用 `LsfxAnalysisClient` 和 `lsfx.api.*` 配置。控制器负责最小参数校验和临时文件转换,独立 `CreditParseClient` 负责 `multipart/form-data` 调用与响应映射,结果以 `AjaxResult.success(response)` 形式透传。
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3, Spring MVC, Jackson, RestTemplate, JUnit 5, Mockito, Maven
|
||||
|
||||
---
|
||||
|
||||
## 文件结构与职责
|
||||
|
||||
**新增文件**
|
||||
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java`
|
||||
负责读取 `credit-parse.api.url`、组装 `multipart/form-data`、调用征信解析服务、记录日志。
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java`
|
||||
负责接收 HTML 文件和可选参数、做最小校验、转换临时文件、调用 `CreditParseClient`、返回 `AjaxResult`。
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java`
|
||||
映射外部返回的 `message`、`status_code`、`payload`。
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java`
|
||||
按三个主题域承载 `payload`,使用 `Map<String, Object>` 保存字段。
|
||||
- `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java`
|
||||
校验 `CreditParseClient` 的参数组装、URL 读取和异常传播。
|
||||
- `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java`
|
||||
校验控制器参数默认值、文件校验、成功透传和异常兜底。
|
||||
- `docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md`
|
||||
记录本次后端实施实际改动、测试命令和结果。
|
||||
|
||||
**修改文件**
|
||||
|
||||
- `ccdi-lsfx/pom.xml`
|
||||
补充测试依赖 `spring-boot-starter-test`。
|
||||
- `ruoyi-admin/src/main/resources/application-dev.yml`
|
||||
新增 `credit-parse.api.url` 配置。
|
||||
- `ruoyi-admin/src/main/resources/application-nas.yml`
|
||||
新增 `credit-parse.api.url` 配置。
|
||||
|
||||
**参考文件**
|
||||
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/LsfxTestController.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java`
|
||||
- `docs/design/2026-03-23-credit-parse-client-design.md`
|
||||
|
||||
## Task 1: 补齐 `ccdi-lsfx` 测试基础
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-lsfx/pom.xml`
|
||||
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java`
|
||||
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java`
|
||||
|
||||
- [ ] **Step 1: 为 `ccdi-lsfx` 添加测试依赖**
|
||||
|
||||
在 `ccdi-lsfx/pom.xml` 追加测试依赖:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 先写控制器失败用例**
|
||||
|
||||
在 `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java` 写出最小失败用例,覆盖空文件与非法后缀:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void parse_shouldRejectEmptyFile() {
|
||||
AjaxResult result = controller.parse(null, null, null);
|
||||
assertEquals(500, result.get("code"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_shouldRejectNonHtmlFile() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "credit.pdf", "application/pdf", "x".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
AjaxResult result = controller.parse(file, null, null);
|
||||
assertEquals(500, result.get("code"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 编译失败,提示 `CreditParseController` 或 `parse(...)` 不存在。
|
||||
|
||||
- [ ] **Step 4: 提交前置依赖与测试骨架**
|
||||
|
||||
```bash
|
||||
git add ccdi-lsfx/pom.xml ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java
|
||||
git commit -m "新增征信解析测试基础"
|
||||
```
|
||||
|
||||
## Task 2: 实现响应对象
|
||||
|
||||
**Files:**
|
||||
- Create: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java`
|
||||
- Create: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java`
|
||||
- Test: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写响应映射测试**
|
||||
|
||||
在 `CreditParseClientTest` 中先写 Jackson 反序列化测试:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldDeserializeCreditParseResponse() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"message": "成功",
|
||||
"status_code": "0",
|
||||
"payload": {
|
||||
"lx_header": {"query_cert_no": "3301"},
|
||||
"lx_debt": {"uncle_bank_house_bal": "12.00"},
|
||||
"lx_publictype": {"civil_cnt": 1}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
CreditParseResponse response = objectMapper.readValue(json, CreditParseResponse.class);
|
||||
|
||||
assertEquals("0", response.getStatusCode());
|
||||
assertEquals("3301", response.getPayload().getLxHeader().get("query_cert_no"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseClientTest#shouldDeserializeCreditParseResponse test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL,提示 `CreditParseResponse` 或 `CreditParsePayload` 不存在。
|
||||
|
||||
- [ ] **Step 3: 编写最小响应对象**
|
||||
|
||||
在 `CreditParseResponse.java` 中实现:
|
||||
|
||||
```java
|
||||
@Data
|
||||
public class CreditParseResponse {
|
||||
|
||||
private String message;
|
||||
|
||||
@JsonProperty("status_code")
|
||||
private String statusCode;
|
||||
|
||||
private CreditParsePayload payload;
|
||||
}
|
||||
```
|
||||
|
||||
在 `CreditParsePayload.java` 中实现:
|
||||
|
||||
```java
|
||||
@Data
|
||||
public class CreditParsePayload {
|
||||
|
||||
@JsonProperty("lx_header")
|
||||
private Map<String, Object> lxHeader;
|
||||
|
||||
@JsonProperty("lx_debt")
|
||||
private Map<String, Object> lxDebt;
|
||||
|
||||
@JsonProperty("lx_publictype")
|
||||
private Map<String, Object> lxPublictype;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseClientTest#shouldDeserializeCreditParseResponse test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 5: 提交响应对象**
|
||||
|
||||
```bash
|
||||
git add ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java
|
||||
git commit -m "新增征信解析响应对象"
|
||||
```
|
||||
|
||||
## Task 3: 实现 `CreditParseClient`
|
||||
|
||||
**Files:**
|
||||
- Create: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java`
|
||||
- Modify: `ruoyi-admin/src/main/resources/application-dev.yml`
|
||||
- Modify: `ruoyi-admin/src/main/resources/application-nas.yml`
|
||||
- Test: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 `Client` 成功用例**
|
||||
|
||||
在 `CreditParseClientTest` 中用 Mockito 模拟 `HttpUtil`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldCallConfiguredUrlWithMultipartParams() {
|
||||
File file = new File("sample.html");
|
||||
CreditParseResponse response = new CreditParseResponse();
|
||||
response.setStatusCode("0");
|
||||
|
||||
when(httpUtil.uploadFile(eq("http://credit-host/xfeature-mngs/conversation/htmlEval"), anyMap(), isNull(), eq(CreditParseResponse.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
CreditParseResponse actual = client.parse("LXCUSTALL", "PERSON", file);
|
||||
|
||||
assertEquals("0", actual.getStatusCode());
|
||||
verify(httpUtil).uploadFile(eq("http://credit-host/xfeature-mngs/conversation/htmlEval"), argThat(params ->
|
||||
"LXCUSTALL".equals(params.get("model"))
|
||||
&& "PERSON".equals(params.get("hType"))
|
||||
&& file.equals(params.get("file"))
|
||||
), isNull(), eq(CreditParseResponse.class));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 再写 `Client` 异常传播用例**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldWrapHttpErrorsAsLsfxApiException() {
|
||||
when(httpUtil.uploadFile(anyString(), anyMap(), isNull(), eq(CreditParseResponse.class)))
|
||||
.thenThrow(new LsfxApiException("网络失败"));
|
||||
|
||||
assertThrows(LsfxApiException.class,
|
||||
() -> client.parse("LXCUSTALL", "PERSON", new File("sample.html")));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseClientTest test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL,提示 `CreditParseClient` 不存在。
|
||||
|
||||
- [ ] **Step 4: 实现 `CreditParseClient`**
|
||||
|
||||
在 `CreditParseClient.java` 中实现最小调用逻辑:
|
||||
|
||||
```java
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CreditParseClient {
|
||||
|
||||
@Resource
|
||||
private HttpUtil httpUtil;
|
||||
|
||||
@Value("${credit-parse.api.url}")
|
||||
private String creditParseUrl;
|
||||
|
||||
public CreditParseResponse parse(String model, String hType, File file) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("model", model);
|
||||
params.put("hType", hType);
|
||||
params.put("file", file);
|
||||
return httpUtil.uploadFile(creditParseUrl, params, null, CreditParseResponse.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
补充日志和异常包装,保持 `LsfxAnalysisClient` 风格:
|
||||
|
||||
- 请求开始记录文件名、`model`、`hType`
|
||||
- 请求结束记录耗时和 `statusCode`
|
||||
- 异常时抛 `LsfxApiException("征信解析调用失败: ...", e)`
|
||||
|
||||
- [ ] **Step 5: 增加独立配置**
|
||||
|
||||
在 `ruoyi-admin/src/main/resources/application-dev.yml` 与 `ruoyi-admin/src/main/resources/application-nas.yml` 增加:
|
||||
|
||||
```yml
|
||||
credit-parse:
|
||||
api:
|
||||
url: http://64.202.94.120:8081/xfeature-mngs/conversation/htmlEval
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 运行测试确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseClientTest test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 7: 提交 `Client` 与配置**
|
||||
|
||||
```bash
|
||||
git add ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java ruoyi-admin/src/main/resources/application-dev.yml ruoyi-admin/src/main/resources/application-nas.yml ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java
|
||||
git commit -m "新增征信解析客户端"
|
||||
```
|
||||
|
||||
## Task 4: 实现 `CreditParseController`
|
||||
|
||||
**Files:**
|
||||
- Create: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java`
|
||||
- Test: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写控制器成功透传用例**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldUseDefaultModelAndTypeWhenMissing() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "credit.html", "text/html", "<html/>".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
CreditParseResponse response = new CreditParseResponse();
|
||||
response.setStatusCode("0");
|
||||
|
||||
when(client.parse(eq("LXCUSTALL"), eq("PERSON"), any(File.class))).thenReturn(response);
|
||||
|
||||
AjaxResult result = controller.parse(file, null, null);
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
assertSame(response, result.get("data"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 再写异常兜底用例**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldReturnAjaxErrorWhenClientThrows() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "credit.html", "text/html", "<html/>".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
when(client.parse(anyString(), anyString(), any(File.class)))
|
||||
.thenThrow(new LsfxApiException("超时"));
|
||||
|
||||
AjaxResult result = controller.parse(file, null, null);
|
||||
|
||||
assertEquals(500, result.get("code"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL,提示 `CreditParseController` 不存在。
|
||||
|
||||
- [ ] **Step 4: 编写控制器最小实现**
|
||||
|
||||
控制器结构按 `LsfxTestController` 风格实现:
|
||||
|
||||
```java
|
||||
@Tag(name = "征信解析接口测试", description = "用于测试征信解析接口")
|
||||
@Anonymous
|
||||
@RestController
|
||||
@RequestMapping("/lsfx/credit")
|
||||
public class CreditParseController {
|
||||
|
||||
@Resource
|
||||
private CreditParseClient creditParseClient;
|
||||
|
||||
@PostMapping("/parse")
|
||||
public AjaxResult parse(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(required = false) String model,
|
||||
@RequestParam(required = false) String hType) {
|
||||
// 参数校验
|
||||
// 默认值补齐
|
||||
// 临时文件转换
|
||||
// 调用 client
|
||||
// finally 删除临时文件
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
校验规则固定为:
|
||||
|
||||
- `file == null` 或 `file.isEmpty()` -> `AjaxResult.error("征信HTML文件不能为空")`
|
||||
- `originalFilename` 为空 -> `AjaxResult.error("文件名不能为空")`
|
||||
- 不是 `.html`/`.htm` -> `AjaxResult.error("仅支持 HTML 格式文件")`
|
||||
- `model` 为空 -> `LXCUSTALL`
|
||||
- `hType` 为空 -> `PERSON`
|
||||
|
||||
- [ ] **Step 5: 运行测试确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 6: 提交控制器**
|
||||
|
||||
```bash
|
||||
git add ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java
|
||||
git commit -m "新增征信解析联调接口"
|
||||
```
|
||||
|
||||
## Task 5: 做模块级回归验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md`
|
||||
|
||||
- [ ] **Step 1: 运行 `ccdi-lsfx` 测试**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx test
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `CreditParseClientTest` PASS
|
||||
- `CreditParseControllerTest` PASS
|
||||
|
||||
- [ ] **Step 2: 运行模块编译验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -am compile
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- BUILD SUCCESS
|
||||
|
||||
- [ ] **Step 3: 手工联调检查 Swagger 与接口**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -am spring-boot:run
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
- `http://localhost:62318/swagger-ui.html`
|
||||
|
||||
检查:
|
||||
|
||||
- 存在 `POST /lsfx/credit/parse`
|
||||
- 上传 `.html` 文件时能返回 `AjaxResult`
|
||||
|
||||
测试完成后停止进程。
|
||||
|
||||
- [ ] **Step 4: 记录实施结果**
|
||||
|
||||
在 `docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md` 写入:
|
||||
|
||||
- 实际修改文件列表
|
||||
- 执行的测试命令
|
||||
- 测试结果
|
||||
- 是否完成 Swagger 联调
|
||||
|
||||
建议结构:
|
||||
|
||||
```md
|
||||
# 征信解析客户端实施记录
|
||||
|
||||
## 1. 改动概述
|
||||
|
||||
## 2. 修改文件
|
||||
|
||||
## 3. 测试记录
|
||||
|
||||
## 4. 结果说明
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交验证与实施记录**
|
||||
|
||||
```bash
|
||||
git add docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md
|
||||
git commit -m "补充征信解析客户端实施记录"
|
||||
```
|
||||
|
||||
## Task 6: 最终整理
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/design/2026-03-23-credit-parse-client-design.md`
|
||||
- Modify: `docs/plans/backend/2026-03-23-credit-parse-client-backend-implementation.md`
|
||||
|
||||
- [ ] **Step 1: 回看设计与实现是否一致**
|
||||
|
||||
逐项核对:
|
||||
|
||||
- 独立 `CreditParseClient`
|
||||
- 独立配置 `credit-parse.api.url`
|
||||
- 接口路径 `POST /lsfx/credit/parse`
|
||||
- 不接入 `ccdi-project`
|
||||
- 不落库
|
||||
|
||||
- [ ] **Step 2: 如果实现偏差,更新设计或实施记录**
|
||||
|
||||
只允许修正文档,不允许在这个阶段临时扩需求。
|
||||
|
||||
- [ ] **Step 3: 检查暂存区只包含本次任务相关文件**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --cached --name-only
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 暂存区仅包含本次征信解析相关代码与文档
|
||||
|
||||
- [ ] **Step 4: 最终提交**
|
||||
|
||||
```bash
|
||||
git add ccdi-lsfx/pom.xml ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java ruoyi-admin/src/main/resources/application-dev.yml ruoyi-admin/src/main/resources/application-nas.yml docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md
|
||||
git commit -m "完成征信解析客户端后端实现"
|
||||
```
|
||||
|
||||
## Review Notes
|
||||
|
||||
- 由于当前仓库协作约定要求“不开启 subagent”,本计划不执行 `writing-plans` 技能中的子代理审阅环节。
|
||||
- 实施时如果发现外部接口真实返回结构与说明书不一致,应先修正响应映射,再更新实施记录,不要额外扩展业务逻辑。
|
||||
@@ -0,0 +1,447 @@
|
||||
# Credit Parsing Mock Server Backend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在现有 `lsfx-mock-server` 中新增征信解析 Mock 能力,提供 `POST /xfeature-mngs/conversation/htmlEval` 接口,支持稳定随机生成征信 `payload`、最小错误码模拟与健康检查能力。
|
||||
|
||||
**Architecture:** 复用现有 FastAPI 应用与测试基础设施,不新建独立服务。征信解析能力拆成 `schema 配置 + payload 生成 service + 调试 service + 独立 router` 四层,接口层只负责 `form-data` 接收与响应组装,字段生成和错误处理分别下沉到独立 service 中,避免与现有 LSFX Mock 逻辑混杂。
|
||||
|
||||
**Tech Stack:** Python 3, FastAPI, pytest, python-multipart, JSON, Bash
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `lsfx-mock-server/config/credit_feature_schema.json`: 固化 Excel 中 30 个征信指标字段的主题域、字段名、类型与枚举范围。
|
||||
- `lsfx-mock-server/config/credit_response_examples.json`: 固化成功与失败响应模板,避免响应结构散落在路由中。
|
||||
- `lsfx-mock-server/services/credit_payload_service.py`: 负责稳定随机种子生成、schema 加载和 `payload` 构造。
|
||||
- `lsfx-mock-server/services/credit_debug_service.py`: 负责参数校验、调试标记识别与错误响应封装。
|
||||
- `lsfx-mock-server/routers/credit_api.py`: 负责新增征信解析接口和征信健康检查接口。
|
||||
- `lsfx-mock-server/main.py`: 注册征信解析 router,并在应用描述中补充接口说明。
|
||||
- `lsfx-mock-server/README.md`: 补充征信解析 Mock 的启动方式、接口路径和调试用法。
|
||||
- `lsfx-mock-server/tests/test_credit_payload_service.py`: 锁定稳定随机与字段类型语义。
|
||||
- `lsfx-mock-server/tests/test_credit_api.py`: 锁定征信解析接口成功、缺参、错误码与健康检查行为。
|
||||
- `lsfx-mock-server/tests/test_startup.py`: 锁定新 router 已被主应用注册。
|
||||
- `docs/reports/implementation/2026-03-23-credit-parsing-mock-server-backend-record.md`: 记录本次后端实施内容。
|
||||
- `docs/tests/records/2026-03-23-credit-parsing-mock-server-backend-verification.md`: 记录本次后端验证命令与结果。
|
||||
|
||||
### Task 1: 建立征信字段 schema 和稳定随机 payload 生成能力
|
||||
|
||||
**Files:**
|
||||
- Create: `lsfx-mock-server/config/credit_feature_schema.json`
|
||||
- Create: `lsfx-mock-server/config/credit_response_examples.json`
|
||||
- Create: `lsfx-mock-server/services/credit_payload_service.py`
|
||||
- Create: `lsfx-mock-server/tests/test_credit_payload_service.py`
|
||||
- Reference: `docs/design/2026-03-23-credit-parsing-mock-server-design.md`
|
||||
- Reference: `assets/征信解析/征信解析接口payload.xlsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
在 `lsfx-mock-server/tests/test_credit_payload_service.py` 中先补两条失败用例,锁定“同一输入返回稳定随机相同结果”和“schema 中的状态字段严格落在枚举范围内”:
|
||||
|
||||
```python
|
||||
from services.credit_payload_service import CreditPayloadService
|
||||
|
||||
|
||||
def test_generate_payload_should_be_stable_for_same_input():
|
||||
service = CreditPayloadService("config/credit_feature_schema.json")
|
||||
|
||||
payload1 = service.generate_payload(
|
||||
model="LXCUSTALL",
|
||||
h_type="PERSON",
|
||||
filename="credit-report-a.html",
|
||||
)
|
||||
payload2 = service.generate_payload(
|
||||
model="LXCUSTALL",
|
||||
h_type="PERSON",
|
||||
filename="credit-report-a.html",
|
||||
)
|
||||
|
||||
assert payload1 == payload2
|
||||
assert set(payload1.keys()) == {"lx_header", "lx_debt", "lx_publictype"}
|
||||
assert len(payload1["lx_debt"]) == 21
|
||||
assert len(payload1["lx_publictype"]) == 6
|
||||
|
||||
|
||||
def test_generate_payload_should_use_schema_type_rules():
|
||||
service = CreditPayloadService("config/credit_feature_schema.json")
|
||||
|
||||
payload = service.generate_payload(
|
||||
model="LXCUSTALL",
|
||||
h_type="ENTERPRISE",
|
||||
filename="credit-report-b.html",
|
||||
)
|
||||
|
||||
assert payload["lx_debt"]["uncle_bank_house_state"] in {"正常", "逾期", "不良"}
|
||||
assert payload["lx_header"]["report_time"].count("-") == 2
|
||||
assert payload["lx_publictype"]["civil_cnt"].isdigit()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_payload_service.py -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `FAIL`
|
||||
- 原因是 `CreditPayloadService`、schema 配置文件和响应模板尚不存在
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
按最小路径落地:
|
||||
|
||||
1. 在 `lsfx-mock-server/config/credit_feature_schema.json` 中把 Excel 30 个字段完整整理为 JSON,至少包含:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "domain": "lx_header", "field": "query_cert_no", "type": "string" },
|
||||
{ "domain": "lx_header", "field": "query_cust_name", "type": "string" },
|
||||
{ "domain": "lx_header", "field": "report_time", "type": "string" },
|
||||
{ "domain": "lx_debt", "field": "uncle_bank_house_state", "type": "status", "options": ["正常", "逾期", "不良"] }
|
||||
]
|
||||
```
|
||||
|
||||
2. 在 `lsfx-mock-server/config/credit_response_examples.json` 中固化成功/失败模板:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": { "message": "成功", "payload": {}, "status_code": "0" },
|
||||
"errors": {
|
||||
"ERR_99999": { "message": "关键参数缺失,参数名: XX", "payload": null, "status_code": "ERR_99999" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 在 `lsfx-mock-server/services/credit_payload_service.py` 中实现最小服务:
|
||||
|
||||
```python
|
||||
class CreditPayloadService:
|
||||
def __init__(self, schema_path: str):
|
||||
self.schema_path = schema_path
|
||||
self.schema = self._load_schema()
|
||||
|
||||
def generate_payload(self, model: str, h_type: str, filename: str) -> dict:
|
||||
rng = random.Random(self._build_seed(model, h_type, filename))
|
||||
...
|
||||
```
|
||||
|
||||
4. 生成规则只保留设计文档确认的 4 类:
|
||||
- `string`
|
||||
- `amount`
|
||||
- `count`
|
||||
- `status`
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_payload_service.py -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `PASS`
|
||||
- 同一输入生成稳定一致结果,不同字段类型输出格式符合约定
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lsfx-mock-server/config/credit_feature_schema.json lsfx-mock-server/config/credit_response_examples.json lsfx-mock-server/services/credit_payload_service.py lsfx-mock-server/tests/test_credit_payload_service.py
|
||||
git commit -m "新增征信解析字段配置与生成服务"
|
||||
```
|
||||
|
||||
### Task 2: 实现参数校验、错误码模拟和征信解析接口
|
||||
|
||||
**Files:**
|
||||
- Create: `lsfx-mock-server/services/credit_debug_service.py`
|
||||
- Create: `lsfx-mock-server/routers/credit_api.py`
|
||||
- Create: `lsfx-mock-server/tests/test_credit_api.py`
|
||||
- Modify: `lsfx-mock-server/tests/conftest.py`
|
||||
- Reference: `docs/design/2026-03-23-credit-parsing-mock-server-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
在 `lsfx-mock-server/tests/test_credit_api.py` 中先补 4 类失败/成功用例,确保接口不走 FastAPI 默认 422,而是走说明书约定的业务响应:
|
||||
|
||||
```python
|
||||
def test_html_eval_should_return_credit_payload(client):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"model": "LXCUSTALL", "hType": "PERSON"},
|
||||
files={"file": ("credit.html", b"<html></html>", "text/html")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status_code"] == "0"
|
||||
assert data["message"] == "成功"
|
||||
assert "lx_header" in data["payload"]
|
||||
|
||||
|
||||
def test_html_eval_should_return_err_99999_for_missing_model(client):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"hType": "PERSON"},
|
||||
files={"file": ("credit.html", b"<html></html>", "text/html")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status_code"] == "ERR_99999"
|
||||
|
||||
|
||||
def test_html_eval_should_return_err_10003_for_invalid_h_type(client):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"model": "LXCUSTALL", "hType": "JSON"},
|
||||
files={"file": ("credit.html", b"<html></html>", "text/html")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status_code"] == "ERR_10003"
|
||||
|
||||
|
||||
def test_html_eval_should_support_debug_error_marker(client):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"model": "error_ERR_10001", "hType": "PERSON"},
|
||||
files={"file": ("credit.html", b"<html></html>", "text/html")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status_code"] == "ERR_10001"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_api.py -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `FAIL`
|
||||
- 原因是征信解析 router 和调试 service 尚不存在
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
按设计文档实现最小闭环:
|
||||
|
||||
1. 在 `lsfx-mock-server/services/credit_debug_service.py` 中定义错误码映射和业务校验入口:
|
||||
|
||||
```python
|
||||
class CreditDebugService:
|
||||
ERROR_MESSAGES = {
|
||||
"ERR_99999": "关键参数缺失,参数名: XX",
|
||||
"ERR_10001": "无效的证件号码",
|
||||
"ERR_10002": "无效的主题域",
|
||||
"ERR_10003": "报文类型无效,仅支持JSON/XML",
|
||||
"ERR_10004": "无效机构号或行社号",
|
||||
"ERR_10005": "无权限访问",
|
||||
"ERR_10006": "尽调报告生成异常:异步事件发送失败",
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `lsfx-mock-server/routers/credit_api.py` 中让参数全部使用可空 `Form(None)` / `File(None)`,再手工校验,避免缺参时被 FastAPI 直接拦成 422:
|
||||
|
||||
```python
|
||||
@router.post("/xfeature-mngs/conversation/htmlEval")
|
||||
async def html_eval(
|
||||
model: Optional[str] = Form(None),
|
||||
hType: Optional[str] = Form(None),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
):
|
||||
...
|
||||
```
|
||||
|
||||
3. 正常流程调用 `CreditPayloadService.generate_payload()`。
|
||||
4. 失败流程统一返回 `message + payload(null) + status_code`。
|
||||
5. 在 `tests/conftest.py` 中按需补共享 fixture,例如测试上传的默认 HTML 文件内容。
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_api.py -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `PASS`
|
||||
- 接口成功、缺参、非法 `hType`、调试错误码都返回预期业务结构
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lsfx-mock-server/services/credit_debug_service.py lsfx-mock-server/routers/credit_api.py lsfx-mock-server/tests/test_credit_api.py lsfx-mock-server/tests/conftest.py
|
||||
git commit -m "新增征信解析接口与错误模拟"
|
||||
```
|
||||
|
||||
### Task 3: 注册新 router 并补启动文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `lsfx-mock-server/main.py`
|
||||
- Modify: `lsfx-mock-server/README.md`
|
||||
- Modify: `lsfx-mock-server/tests/test_startup.py`
|
||||
- Reference: `lsfx-mock-server/dev.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
先在 `lsfx-mock-server/tests/test_startup.py` 中补应用注册断言,锁定主应用已经包含征信解析接口和健康检查接口:
|
||||
|
||||
```python
|
||||
from main import app
|
||||
|
||||
|
||||
def test_app_should_register_credit_mock_routes():
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/xfeature-mngs/conversation/htmlEval" in paths
|
||||
assert "/credit/health" in paths
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_startup.py -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `FAIL`
|
||||
- 原因是 `main.py` 还未注册征信解析 router
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
1. 在 `lsfx-mock-server/main.py` 中引入并注册 `credit_api.router`:
|
||||
|
||||
```python
|
||||
from routers import api, credit_api
|
||||
|
||||
app.include_router(api.router, tags=["流水分析接口"])
|
||||
app.include_router(credit_api.router, tags=["征信解析接口"])
|
||||
```
|
||||
|
||||
2. 在应用描述中补充征信解析能力说明。
|
||||
3. 在 `lsfx-mock-server/README.md` 中补充:
|
||||
- 新接口路径
|
||||
- `curl` 或 `requests` 调用示例
|
||||
- `error_ERR_10001` 调试方式
|
||||
- `GET /credit/health` 用法
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_startup.py tests/test_credit_api.py -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `PASS`
|
||||
- 主应用已注册征信解析 router,README 与运行方式保持一致
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lsfx-mock-server/main.py lsfx-mock-server/README.md lsfx-mock-server/tests/test_startup.py
|
||||
git commit -m "注册征信解析Mock路由并补充说明"
|
||||
```
|
||||
|
||||
### Task 4: 端到端验证并沉淀实施记录
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/reports/implementation/2026-03-23-credit-parsing-mock-server-backend-record.md`
|
||||
- Create: `docs/tests/records/2026-03-23-credit-parsing-mock-server-backend-verification.md`
|
||||
- Reference: `docs/design/2026-03-23-credit-parsing-mock-server-design.md`
|
||||
- Reference: `docs/plans/backend/2026-03-23-credit-parsing-mock-server-backend-implementation.md`
|
||||
|
||||
- [ ] **Step 1: Run targeted automated tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_payload_service.py tests/test_credit_api.py tests/test_startup.py -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `PASS`
|
||||
- 征信字段生成、接口返回和路由注册全部通过
|
||||
|
||||
- [ ] **Step 2: Run manual startup and smoke verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 main.py > /tmp/credit-mock-server.log 2>&1 &
|
||||
echo $! > /tmp/credit-mock-server.pid
|
||||
```
|
||||
|
||||
再执行:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/credit/health
|
||||
|
||||
curl -s -X POST http://127.0.0.1:8000/xfeature-mngs/conversation/htmlEval \
|
||||
-F model=LXCUSTALL \
|
||||
-F hType=PERSON \
|
||||
-F file=@/tmp/sample-credit.html
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 健康检查返回 `healthy`
|
||||
- 成功接口返回 `status_code: "0"`,且 `payload` 包含三大主题域
|
||||
|
||||
- [ ] **Step 3: Stop the started process**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
kill "$(cat /tmp/credit-mock-server.pid)"
|
||||
rm -f /tmp/credit-mock-server.pid
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 本次验证启动的 Mock 进程已停止,不留下端口占用
|
||||
|
||||
- [ ] **Step 4: Write implementation and verification records**
|
||||
|
||||
在 `docs/reports/implementation/2026-03-23-credit-parsing-mock-server-backend-record.md` 中记录:
|
||||
|
||||
- 新增接口、配置文件、service 和测试文件
|
||||
- 稳定随机策略
|
||||
- 说明书歧义处理方式
|
||||
|
||||
在 `docs/tests/records/2026-03-23-credit-parsing-mock-server-backend-verification.md` 中记录:
|
||||
|
||||
- 自动化测试命令与结果
|
||||
- 手工 `curl` 验证命令与结果
|
||||
- 启停服务过程
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/reports/implementation/2026-03-23-credit-parsing-mock-server-backend-record.md docs/tests/records/2026-03-23-credit-parsing-mock-server-backend-verification.md
|
||||
git commit -m "补充征信解析Mock后端实施与验证记录"
|
||||
```
|
||||
@@ -0,0 +1,152 @@
|
||||
# Credit Parse Client Frontend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 确认本次征信解析客户端建设不涉及前端代码改动,并输出可执行的前端协作与验收计划,避免后续联调时误改前端现有上传链路。
|
||||
|
||||
**Architecture:** 本次需求边界限定在 `ccdi-lsfx` 后端模块:新增独立征信解析 `Client`、独立配置和联调接口,不接入 `ccdi-project` 现有上传流程,也不新增前端页面或 API 封装。因此前端计划以“确认无改动、保留联调信息、避免误接入”为主。
|
||||
|
||||
**Tech Stack:** Vue 2, Axios request 封装, 若依前端工程结构, Markdown 文档
|
||||
|
||||
---
|
||||
|
||||
## 文件结构与职责
|
||||
|
||||
**本次不修改的前端文件**
|
||||
|
||||
- `ruoyi-ui/src/api/ccdiProjectUpload.js`
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**新增文件**
|
||||
|
||||
- `docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md`
|
||||
在后端实施完成后,由同一次改动统一补充实施记录,其中需明确本次前端无代码改动。
|
||||
|
||||
**参考文件**
|
||||
|
||||
- `docs/design/2026-03-23-credit-parse-client-design.md`
|
||||
- `docs/plans/backend/2026-03-23-credit-parse-client-backend-implementation.md`
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
## Task 1: 确认前端边界
|
||||
|
||||
**Files:**
|
||||
- Review: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
- Review: `ruoyi-ui/src/api/ccdiProjectUpload.js`
|
||||
|
||||
- [ ] **Step 1: 核对设计边界**
|
||||
|
||||
确认以下结论成立:
|
||||
|
||||
- 新接口落在 `ccdi-lsfx`
|
||||
- 调用入口为 `POST /lsfx/credit/parse`
|
||||
- 不接入项目上传主链路
|
||||
- 不新增前端上传卡片行为
|
||||
- 不新增前端 API 文件
|
||||
|
||||
- [ ] **Step 2: 检查现有前端征信入口不做误修改**
|
||||
|
||||
查看 `UploadData.vue` 中现有征信卡片定义,确认本次不改:
|
||||
|
||||
```js
|
||||
{
|
||||
key: "credit",
|
||||
title: "征信导入",
|
||||
desc: "支持 HTML 格式征信数据解析",
|
||||
icon: "el-icon-s-data",
|
||||
btnText: "上传征信",
|
||||
uploaded: false,
|
||||
disabled: true,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 记录前端不改动结论**
|
||||
|
||||
在实施记录中预留一段文字:
|
||||
|
||||
```md
|
||||
## 前端影响说明
|
||||
|
||||
本次需求仅在 `ccdi-lsfx` 后端模块新增征信解析 Client 与联调接口,前端未做代码改动。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交边界确认**
|
||||
|
||||
```bash
|
||||
git add docs/plans/frontend/2026-03-23-credit-parse-client-frontend-implementation.md
|
||||
git commit -m "新增征信解析客户端前端实施计划"
|
||||
```
|
||||
|
||||
## Task 2: 后端联调验收配合
|
||||
|
||||
**Files:**
|
||||
- Review: `docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md`
|
||||
|
||||
- [ ] **Step 1: 后端完成后校验接口契约**
|
||||
|
||||
核对后端实际输出是否满足以下约定:
|
||||
|
||||
- `AjaxResult.code = 200`
|
||||
- `AjaxResult.data.message`
|
||||
- `AjaxResult.data.status_code`
|
||||
- `AjaxResult.data.payload.lx_header`
|
||||
- `AjaxResult.data.payload.lx_debt`
|
||||
- `AjaxResult.data.payload.lx_publictype`
|
||||
|
||||
- [ ] **Step 2: 确认无前端阻塞项**
|
||||
|
||||
若没有以下情况,则维持前端零改动:
|
||||
|
||||
- 需要在项目页面挂接新按钮
|
||||
- 需要新增前端 API 封装
|
||||
- 需要前端解析 `payload` 并渲染页面
|
||||
|
||||
若出现上述新需求,应单独发起新的设计和计划,不在本次实施中顺带处理。
|
||||
|
||||
- [ ] **Step 3: 在实施记录中写明前端验收结论**
|
||||
|
||||
补充:
|
||||
|
||||
```md
|
||||
## 前端验收结论
|
||||
|
||||
- 本次无前端代码改动
|
||||
- 后端接口契约已形成,可供后续独立前端联调使用
|
||||
```
|
||||
|
||||
## Task 3: 最终检查
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/frontend/2026-03-23-credit-parse-client-frontend-implementation.md`
|
||||
|
||||
- [ ] **Step 1: 检查工作区无前端源码改动**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short ruoyi-ui
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 无 `ruoyi-ui` 目录下源码改动,或只有用户明确要求的相关改动
|
||||
|
||||
- [ ] **Step 2: 检查计划与设计一致**
|
||||
|
||||
确认没有出现以下偏移:
|
||||
|
||||
- 把征信解析顺手接入前端上传卡片
|
||||
- 顺手新增前端 API 封装
|
||||
- 顺手调整项目上传状态逻辑
|
||||
|
||||
- [ ] **Step 3: 最终提交**
|
||||
|
||||
```bash
|
||||
git add docs/plans/frontend/2026-03-23-credit-parse-client-frontend-implementation.md
|
||||
git commit -m "补充征信解析客户端前端实施说明"
|
||||
```
|
||||
|
||||
## Review Notes
|
||||
|
||||
- 本计划明确为“前端零代码改动计划”,目的是固化边界,避免后续联调时无意改动现有项目上传前端链路。
|
||||
- 由于当前协作约定禁止开启 subagent,本计划不执行子代理审阅环节。
|
||||
@@ -0,0 +1,124 @@
|
||||
# Credit Parsing Mock Server Frontend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在不新增前端页面和业务补丁的前提下,明确本次征信解析 Mock Server 建设对前端的影响边界,并沉淀联调核验与零改动结论。
|
||||
|
||||
**Architecture:** 本次需求主体是 `lsfx-mock-server` 的后端 Mock 能力建设,不要求 `ruoyi-ui` 新增页面、路由、接口封装或交互。前端计划采用“零源码改动 + 契约核验 + 记录沉淀”的最短路径;若核验发现必须新增前端消费字段,则应停止执行并回到设计阶段,而不是在本计划中临时扩展功能。
|
||||
|
||||
**Tech Stack:** Vue 2, Axios request wrapper, rg, Markdown docs
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `ruoyi-ui/src/api/`: 只用于核验当前仓库是否已有征信解析相关调用,不预期修改。
|
||||
- `ruoyi-ui/src/views/`: 只用于核验是否存在需要接入征信解析 Mock 的页面触点,不预期修改。
|
||||
- `ruoyi-ui/src/utils/request.js`: 只用于确认现有请求封装是否足以承接未来联调,不预期修改。
|
||||
- `docs/reports/implementation/2026-03-23-credit-parsing-mock-server-frontend-record.md`: 记录本次前端零代码改动的范围与依据。
|
||||
- `docs/tests/records/2026-03-23-credit-parsing-mock-server-frontend-verification.md`: 记录前端契约核验命令、结果和结论。
|
||||
|
||||
### Task 1: 核验当前前端不存在征信解析 Mock 接入改造需求
|
||||
|
||||
**Files:**
|
||||
- Reference: `ruoyi-ui/src/api/`
|
||||
- Reference: `ruoyi-ui/src/views/`
|
||||
- Reference: `ruoyi-ui/src/utils/request.js`
|
||||
- Reference: `docs/design/2026-03-23-credit-parsing-mock-server-design.md`
|
||||
|
||||
- [ ] **Step 1: Check the existing frontend touchpoints**
|
||||
|
||||
检查当前前端是否已经存在以下任一触点:
|
||||
|
||||
- 征信 HTML 上传入口
|
||||
- 征信解析结果展示页
|
||||
- 指向 `htmlEval`、`xfeature`、`征信解析` 的接口封装
|
||||
|
||||
如无上述触点,则本次计划默认保持前端零代码改动。
|
||||
|
||||
- [ ] **Step 2: Verify with search commands**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd ruoyi-ui
|
||||
rg -n "htmlEval|xfeature|征信|credit" src
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 若无输出或仅命中无关文字,说明当前前端仓库没有现成的征信解析接入点
|
||||
- 本次 Mock Server 不需要同步新增前端代码
|
||||
|
||||
- [ ] **Step 3: Confirm no contract adaptation is needed**
|
||||
|
||||
对照设计文档确认:
|
||||
|
||||
- Mock 接口为独立服务能力,不替换现有前端接口返回结构
|
||||
- 不要求前端新增必填请求字段
|
||||
- 不要求前端渲染新增结果列或交互入口
|
||||
|
||||
若上述任一不成立,则停止执行并回到设计阶段。
|
||||
|
||||
- [ ] **Step 4: Record the no-op conclusion**
|
||||
|
||||
在后续实施记录中明确写明:
|
||||
|
||||
- 本次需求不涉及 `ruoyi-ui` 源码改动
|
||||
- 不为了“看起来完整”而额外创建演示页或调试页
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/reports/implementation/2026-03-23-credit-parsing-mock-server-frontend-record.md docs/tests/records/2026-03-23-credit-parsing-mock-server-frontend-verification.md
|
||||
git commit -m "记录征信解析Mock前端零改动结论"
|
||||
```
|
||||
|
||||
### Task 2: 沉淀前端联调与验证记录
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/reports/implementation/2026-03-23-credit-parsing-mock-server-frontend-record.md`
|
||||
- Create: `docs/tests/records/2026-03-23-credit-parsing-mock-server-frontend-verification.md`
|
||||
|
||||
- [ ] **Step 1: Write implementation record**
|
||||
|
||||
在 `docs/reports/implementation/2026-03-23-credit-parsing-mock-server-frontend-record.md` 中记录:
|
||||
|
||||
- 本次需求核心在 Mock 服务后端
|
||||
- 当前前端仓库不存在征信解析接入点
|
||||
- 因此本轮实施不修改 `ruoyi-ui` 任何源码
|
||||
|
||||
- [ ] **Step 2: Write verification record**
|
||||
|
||||
在 `docs/tests/records/2026-03-23-credit-parsing-mock-server-frontend-verification.md` 中记录:
|
||||
|
||||
- 执行过的 `rg` 核验命令
|
||||
- 查验的目录范围
|
||||
- “无需前端改动”的判断依据
|
||||
|
||||
- [ ] **Step 3: Verify frontend diff stays empty**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --name-only -- ruoyi-ui
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 无输出
|
||||
- 证明本次前端计划执行保持零源码改动
|
||||
|
||||
- [ ] **Step 4: Confirm no frontend build is required**
|
||||
|
||||
在验证记录中明确写明:
|
||||
|
||||
- 因为 `ruoyi-ui` 无源码改动,本次不执行 `npm run build:prod`
|
||||
- 若后续新增实际接入页面,再补充构建和联调测试
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/reports/implementation/2026-03-23-credit-parsing-mock-server-frontend-record.md docs/tests/records/2026-03-23-credit-parsing-mock-server-frontend-verification.md
|
||||
git commit -m "补充征信解析Mock前端核验记录"
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# 征信解析客户端实施记录
|
||||
|
||||
## 1. 改动概述
|
||||
|
||||
本次在 `ccdi-lsfx` 模块新增了独立征信解析能力,包含以下内容:
|
||||
|
||||
- 新增 `CreditParseClient`,通过独立配置 `credit-parse.api.url` 调用外部征信解析服务。
|
||||
- 新增 `POST /lsfx/credit/parse` 联调接口,支持上传 `.html/.htm` 文件、默认补齐 `model=LXCUSTALL` 与 `hType=PERSON`,并将结果以 `AjaxResult` 形式返回。
|
||||
- 新增征信解析响应对象 `CreditParseResponse`、`CreditParsePayload`。
|
||||
- 为 `ccdi-lsfx` 补充 `spring-boot-starter-test` 依赖,并新增客户端、控制器单元测试。
|
||||
|
||||
## 2. 修改文件
|
||||
|
||||
- `ccdi-lsfx/pom.xml`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java`
|
||||
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java`
|
||||
- `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java`
|
||||
- `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java`
|
||||
- `ruoyi-admin/src/main/resources/application-dev.yml`
|
||||
- `ruoyi-admin/src/main/resources/application-nas.yml`
|
||||
|
||||
## 3. 测试记录
|
||||
|
||||
### 3.1 TDD 过程命令
|
||||
|
||||
执行过的关键测试命令如下:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseClientTest#shouldDeserializeCreditParseResponse test
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseClientTest test
|
||||
mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `CreditParseControllerTest` 首次运行按预期失败,定位为 `CreditParseController` 不存在。
|
||||
- `CreditParseClientTest#shouldDeserializeCreditParseResponse` 首次运行按预期失败,定位为响应对象不存在。
|
||||
- `CreditParseClientTest` 与 `CreditParseControllerTest` 最终均通过。
|
||||
|
||||
### 3.2 模块回归命令
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx test
|
||||
mvn -pl ccdi-lsfx -am compile
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `mvn -pl ccdi-lsfx test`:通过,`CreditParseClientTest` 和 `CreditParseControllerTest` 共 7 个测试全部通过。
|
||||
- `mvn -pl ccdi-lsfx -am compile`:通过,`ruoyi-common`、`ccdi-lsfx` 编译成功。
|
||||
|
||||
### 3.3 Swagger / 接口联调
|
||||
|
||||
执行过程:
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -am install -DskipTests
|
||||
cd ruoyi-admin && mvn spring-boot:run
|
||||
curl -I http://127.0.0.1:62318/swagger-ui.html
|
||||
curl http://127.0.0.1:62318/v3/api-docs
|
||||
curl -F 'file=@/tmp/credit-parse-sample.html;type=text/html' http://127.0.0.1:62318/lsfx/credit/parse
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `swagger-ui.html` 可正常跳转到 `/swagger-ui/index.html`。
|
||||
- OpenAPI 文档中已存在 `征信解析接口测试` 标签以及 `POST /lsfx/credit/parse` 路径。
|
||||
- 上传 `.html` 文件时,请求已进入 `CreditParseController` 与 `CreditParseClient`,但外部地址 `http://64.202.94.120:8081/xfeature-mngs/conversation/htmlEval` 在联调时返回 `The target server failed to respond`,因此未拿到成功解析结果。
|
||||
- 联调结束后已停止 `ruoyi-admin` 启动进程,未保留端口占用。
|
||||
|
||||
## 4. 结果说明
|
||||
|
||||
- 设计范围内的后端代码已完成,未接入 `ccdi-project`,未新增落库逻辑。
|
||||
- 独立配置 `credit-parse.api.url` 已在 `application-dev.yml` 与 `application-nas.yml` 中补齐。
|
||||
- 控制器已实现文件校验、默认值补齐、临时文件转换、调用客户端和临时文件清理。
|
||||
- 客户端已实现调用日志、耗时记录和统一异常包装。
|
||||
- 本次联调结论为:系统侧接口注册、Swagger 暴露和请求链路已打通;外部征信解析服务当前未响应,需待对方服务可用后继续做成功结果验证。
|
||||
|
||||
## 5. 追加调整记录
|
||||
|
||||
- 为便于本地联调,已将 `application-dev.yml` 中的 `credit-parse.api.url` 调整为本地 Mock 地址:`http://localhost:8000/xfeature-mngs/conversation/htmlEval`。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 征信解析 Mock Server 后端实施记录
|
||||
|
||||
## 本次改动
|
||||
|
||||
- 新增 [`credit_feature_schema.json`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/config/credit_feature_schema.json),将 Excel 中 30 个征信字段按 `lx_header`、`lx_debt`、`lx_publictype` 三个主题域固化为 schema,并统一映射到 `string`、`amount`、`count`、`status` 四类生成规则。
|
||||
- 新增 [`credit_response_examples.json`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/config/credit_response_examples.json),沉淀成功模板和 `ERR_99999`、`ERR_10001` 至 `ERR_10006` 的失败模板。
|
||||
- 新增 [`credit_payload_service.py`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/services/credit_payload_service.py),负责加载 schema,并基于 `model + hType + filename` 生成稳定随机 payload。
|
||||
- 新增 [`credit_debug_service.py`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/services/credit_debug_service.py),负责参数缺失校验、调试错误码识别、成功/失败响应封装。
|
||||
- 新增 [`credit_api.py`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/routers/credit_api.py),提供:
|
||||
- `POST /xfeature-mngs/conversation/htmlEval`
|
||||
- `GET /credit/health`
|
||||
- 更新 [`main.py`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/main.py),将征信解析 router 注册到主应用,并补充应用描述。
|
||||
- 更新 [`README.md`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/README.md),补充征信解析 Mock 的调用示例、错误码调试方式和健康检查说明。
|
||||
- 新增测试文件 [`test_credit_payload_service.py`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/tests/test_credit_payload_service.py)、[`test_credit_api.py`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/tests/test_credit_api.py),并补充 [`test_startup.py`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex-credit-parsing-mock-server-backend/lsfx-mock-server/tests/test_startup.py) 的路由注册断言。
|
||||
|
||||
## 实现说明
|
||||
|
||||
### 稳定随机策略
|
||||
|
||||
- 随机种子固定为 `model|hType|filename`。
|
||||
- 同一组输入重复调用时,`payload` 全量字段保持一致。
|
||||
- `amount` 输出为非负两位小数字符串。
|
||||
- `count` 输出为非负整数字符串。
|
||||
- `status` 严格从 schema 的 `options` 中选值,当前统一为 `正常`、`逾期`、`不良`。
|
||||
- `string` 类型按字段语义生成:
|
||||
- `query_cert_no`:身份证号样式字符串
|
||||
- `query_cust_name`:中文姓名样式字符串
|
||||
- `report_time`:`YYYY-MM-DD`
|
||||
|
||||
### 说明书歧义处理
|
||||
|
||||
- 设计文档中已指出 `hType` 参数表写明只支持 `PERSON/ENTERPRISE`,但错误码 `ERR_10003` 的文案仍保留“仅支持JSON/XML”。
|
||||
- 本次实现按参数表校验真实取值范围,只要 `hType` 不在 `PERSON/ENTERPRISE` 内,就返回 `ERR_10003`,同时保留说明书原始错误文案不改写。
|
||||
|
||||
### 接口行为
|
||||
|
||||
- 接口层所有表单参数均使用 `Form(None)` / `File(None)` 接收,避免缺参时被 FastAPI 直接转换成 422。
|
||||
- 缺参、非法主题域、非法 `hType`、调试错误码场景统一返回 `{message, payload, status_code}` 结构。
|
||||
- 调试错误码通过 `model` 中包含 `error_ERR_10001` 这类标记触发。
|
||||
|
||||
## 未包含内容
|
||||
|
||||
- 未实现真实 HTML 解析。
|
||||
- 未新增设计文档之外的征信接口。
|
||||
- 未引入运行时动态配置或管理页面。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 个人中介中介子类型与关联关系调整实施记录
|
||||
|
||||
## 1. 改动概述
|
||||
|
||||
本次调整“信息维护-中介库管理-个人中介”录入与详情展示逻辑,目标如下:
|
||||
|
||||
- 将个人中介表单中的“人员子类型”改为“中介子类型”。
|
||||
- “中介子类型”改为从关联关系选项中选择,并补充“个人”选项。
|
||||
- 隐藏个人中介表单中单独的“关联关系”字段,避免重复录入。
|
||||
- 详情弹窗中同步以“中介子类型”展示原关联关系语义,移除重复的“关系类型”展示。
|
||||
|
||||
## 2. 修改文件
|
||||
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
|
||||
- `ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js`
|
||||
|
||||
## 3. 实现说明
|
||||
|
||||
### 3.1 编辑弹窗
|
||||
|
||||
- 将个人中介表单字段标签由“人员子类型”调整为“中介子类型”。
|
||||
- 将原自由输入框改为下拉选择,数据源基于现有 `relationTypeOptions`,前端追加 `个人` 选项并做去重。
|
||||
- 新增 `handlePersonSubTypeChange` 与 `syncPersonSubTypeRelation`,确保隐藏 `relationType` 后,提交时 `personSubType` 与 `relationType` 保持一致。
|
||||
- 移除个人中介表单中单独展示的“关联关系”字段。
|
||||
|
||||
### 3.2 详情弹窗
|
||||
|
||||
- 将详情中的“人员子类型”展示调整为“中介子类型”。
|
||||
- 展示值优先取 `personSubType`,兼容回退到 `relationType`,避免存量数据为空时页面显示缺失。
|
||||
- 删除重复的“关系类型”展示项。
|
||||
|
||||
### 3.3 回归测试
|
||||
|
||||
- 新增源码级测试 `intermediary-person-edit-ui.test.js`,约束以下行为:
|
||||
- 编辑弹窗必须展示“中介子类型”下拉。
|
||||
- 选项中必须包含“个人”。
|
||||
- 编辑弹窗不再单独展示“关联关系”字段。
|
||||
- 详情弹窗以“中介子类型”展示,不再单独展示“关系类型”。
|
||||
|
||||
## 4. 验证记录
|
||||
|
||||
执行命令:
|
||||
|
||||
```bash
|
||||
node /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js
|
||||
npm run build:prod
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
- `node .../intermediary-person-edit-ui.test.js`:先红后绿,最终通过。
|
||||
- `npm run build:prod`:构建通过,仅存在项目原有的产物体积告警,无新增编译错误。
|
||||
@@ -0,0 +1,28 @@
|
||||
# Project 51 预警人数异常修复记录
|
||||
|
||||
## 本次改动
|
||||
|
||||
- 修复 `ccdi-project` 模块中采购类规则未按项目人员范围过滤的问题。
|
||||
- 在 `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml` 新增 `projectScopedDirectStaffSql` 片段,统一收敛“当前项目流水中已识别员工”的范围。
|
||||
- 将 `selectLargePurchaseTransactionStatements` 改为仅统计当前项目员工对应的采购记录。
|
||||
- 将 `selectSupplierConcentrationObjects` 改为仅统计当前项目员工对应的采购记录,避免把其他项目或全局采购数据映射进当前项目预警结果。
|
||||
- 在 `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java` 新增回归测试,约束采购规则必须引用项目员工范围片段。
|
||||
|
||||
## 根因说明
|
||||
|
||||
- `project_id = 51` 的项目总人数来自 `ccdi_project.target_count`,当前值为 `1`。
|
||||
- 预警人数来自 `ccdi_project_overview_employee_result` 聚合结果,排查发现其中除了上传文件对应员工 `韩桂英` 外,还混入了 `罗洋`。
|
||||
- 进一步核对 `ccdi_bank_statement_tag_result` 后确认,多出的 `罗洋` 来自 `SUPPLIER_CONCENTRATION` 规则生成的对象命中结果。
|
||||
- 原始 SQL 直接扫描 `ccdi_purchase_transaction` 全表,并通过员工工号映射到 `ccdi_base_staff`,没有任何 `projectId` 或项目人员范围限制,导致全局采购数据串入当前项目。
|
||||
|
||||
## 变更文件
|
||||
|
||||
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
|
||||
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 通过数据库核对确认:`project_id = 51` 当前仅上传成功 1 个文件,但 `ccdi_project_overview_employee_result` 中存在 2 名员工,异常现象可复现。
|
||||
- 执行 `mvn -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest test`
|
||||
- 结果:`BUILD SUCCESS`
|
||||
- 新增回归测试已覆盖采购规则项目范围约束,防止后续再次把全局采购员工带入单个项目的预警统计。
|
||||
@@ -0,0 +1,108 @@
|
||||
# 征信解析 Mock Server 后端验证记录
|
||||
|
||||
## 自动化验证
|
||||
|
||||
### 1. Payload Service 红绿验证
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_payload_service.py -v
|
||||
```
|
||||
|
||||
- 首次执行结果:失败
|
||||
- 原因:`services.credit_payload_service` 不存在
|
||||
- 实现后再次执行结果:通过
|
||||
|
||||
### 2. 接口层红绿验证
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_api.py -v
|
||||
```
|
||||
|
||||
- 首次执行结果:失败
|
||||
- 原因:`/xfeature-mngs/conversation/htmlEval` 尚未注册,返回 `404`
|
||||
- 实现后再次执行结果:通过
|
||||
|
||||
### 3. 启动注册验证
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_startup.py -v
|
||||
```
|
||||
|
||||
- 首次执行结果:失败
|
||||
- 原因:主应用尚未注册征信解析路由
|
||||
- 实现后再次执行结果:通过
|
||||
|
||||
### 4. 目标回归验证
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 -m pytest tests/test_credit_payload_service.py tests/test_credit_api.py tests/test_startup.py -v
|
||||
```
|
||||
|
||||
- 结果:`10 passed`
|
||||
|
||||
## 手工冒烟验证
|
||||
|
||||
### 启动过程
|
||||
|
||||
首次按设计默认端口 `8000` 冒烟时,发现本机已有其他 Python 进程占用 `8000`,`curl` 实际命中了外部已有服务而非当前 worktree 内的 Mock 服务。为避免环境噪音,本次手工验证改用 `18000` 端口前台启动当前应用:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
PORT=18000 python3 main.py
|
||||
```
|
||||
|
||||
启动日志确认:
|
||||
|
||||
- `Application startup complete.`
|
||||
- `Uvicorn running on http://0.0.0.0:18000`
|
||||
|
||||
### 健康检查验证
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:18000/credit/health
|
||||
```
|
||||
|
||||
返回结果:
|
||||
|
||||
```json
|
||||
{"status":"healthy","service":"credit-mock"}
|
||||
```
|
||||
|
||||
### 征信解析接口验证
|
||||
|
||||
准备示例文件:
|
||||
|
||||
```bash
|
||||
printf '<html></html>' > /tmp/sample-credit.html
|
||||
```
|
||||
|
||||
执行请求:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:18000/xfeature-mngs/conversation/htmlEval \
|
||||
-F model=LXCUSTALL \
|
||||
-F hType=PERSON \
|
||||
-F file=@/tmp/sample-credit.html
|
||||
```
|
||||
|
||||
返回结果要点:
|
||||
|
||||
- `status_code` 为 `"0"`
|
||||
- `message` 为 `"成功"`
|
||||
- `payload` 包含 `lx_header`、`lx_debt`、`lx_publictype`
|
||||
- `lx_header.report_time` 为日期格式字符串
|
||||
- `lx_debt` 下 21 个字段、`lx_publictype` 下 6 个字段均已生成
|
||||
|
||||
### 服务停止
|
||||
|
||||
本次手工验证采用前台启动,验证完成后已通过 `Ctrl+C` 停止服务,确认未残留由本次验证启动的 Mock 进程。
|
||||
|
||||
## 结论
|
||||
|
||||
- 征信字段 schema 读取、稳定随机 payload 生成、错误码模拟、主应用路由注册均符合计划要求。
|
||||
- 自动化测试与手工冒烟验证均通过。
|
||||
- 本地环境存在外部进程占用 `8000` 的情况,但不影响当前实现正确性;本次已通过切换到空闲端口完成隔离验证。
|
||||
@@ -1,12 +1,13 @@
|
||||
# 流水分析 Mock 服务器
|
||||
|
||||
基于 Python + FastAPI 的独立 Mock 服务器,用于模拟流水分析平台的 7 个核心接口。
|
||||
基于 Python + FastAPI 的独立 Mock 服务器,用于模拟流水分析平台的 7 个核心接口,并提供征信解析 Mock 能力。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- ✅ **完整的接口模拟** - 实现所有 7 个核心接口
|
||||
- ✅ **文件解析延迟** - 使用 FastAPI 后台任务模拟 4 秒解析延迟
|
||||
- ✅ **错误场景触发** - 通过 `error_XXXX` 标记触发所有 8 个错误码
|
||||
- ✅ **征信解析 Mock** - 支持稳定随机生成三大主题域征信 `payload`
|
||||
- ✅ **自动 API 文档** - Swagger UI 和 ReDoc 自动生成
|
||||
- ✅ **配置驱动** - JSON 模板文件,易于修改响应数据
|
||||
- ✅ **零配置启动** - 开箱即用,无需数据库
|
||||
@@ -100,6 +101,44 @@ response = requests.post(
|
||||
)
|
||||
```
|
||||
|
||||
### 征信解析 Mock
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8000/xfeature-mngs/conversation/htmlEval \
|
||||
-F model=LXCUSTALL \
|
||||
-F hType=PERSON \
|
||||
-F file=@./sample-credit.html
|
||||
```
|
||||
|
||||
成功时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "成功",
|
||||
"status_code": "0",
|
||||
"payload": {
|
||||
"lx_header": {},
|
||||
"lx_debt": {},
|
||||
"lx_publictype": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
调试错误码时,可在 `model` 中追加错误标记:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8000/xfeature-mngs/conversation/htmlEval \
|
||||
-F model=error_ERR_10001 \
|
||||
-F hType=PERSON \
|
||||
-F file=@./sample-credit.html
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8000/credit/health
|
||||
```
|
||||
|
||||
### 错误场景测试
|
||||
|
||||
```python
|
||||
@@ -213,6 +252,8 @@ pytest tests/ -v --cov=. --cov-report=html
|
||||
| 4 | POST | `/watson/api/project/upload/getpendings` | 检查解析状态 |
|
||||
| 5 | POST | `/watson/api/project/batchDeleteUploadFile` | 删除文件 |
|
||||
| 6 | POST | `/watson/api/project/getBSByLogId` | 获取银行流水 |
|
||||
| 7 | POST | `/xfeature-mngs/conversation/htmlEval` | 征信解析 Mock |
|
||||
| 8 | GET | `/credit/health` | 征信解析健康检查 |
|
||||
|
||||
## ⚠️ 错误码列表
|
||||
|
||||
|
||||
159
lsfx-mock-server/config/credit_feature_schema.json
Normal file
159
lsfx-mock-server/config/credit_feature_schema.json
Normal file
@@ -0,0 +1,159 @@
|
||||
[
|
||||
{
|
||||
"domain": "lx_header",
|
||||
"field": "query_cert_no",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"domain": "lx_header",
|
||||
"field": "query_cust_name",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"domain": "lx_header",
|
||||
"field": "report_time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_house_bal",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_house_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_house_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_car_bal",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_car_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_car_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_manage_bal",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_manage_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_manage_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_consume_bal",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_consume_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_consume_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_other_bal",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_other_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_bank_other_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_not_bank_bal",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_not_bank_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_not_bank_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_credit_cart_bal",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_credit_cart_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_debt",
|
||||
"field": "uncle_credit_cart_state",
|
||||
"type": "status",
|
||||
"options": ["正常", "逾期", "不良"]
|
||||
},
|
||||
{
|
||||
"domain": "lx_publictype",
|
||||
"field": "civil_cnt",
|
||||
"type": "count"
|
||||
},
|
||||
{
|
||||
"domain": "lx_publictype",
|
||||
"field": "enforce_cnt",
|
||||
"type": "count"
|
||||
},
|
||||
{
|
||||
"domain": "lx_publictype",
|
||||
"field": "adm_cnt",
|
||||
"type": "count"
|
||||
},
|
||||
{
|
||||
"domain": "lx_publictype",
|
||||
"field": "civil_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_publictype",
|
||||
"field": "enforce_lmt",
|
||||
"type": "amount"
|
||||
},
|
||||
{
|
||||
"domain": "lx_publictype",
|
||||
"field": "adm_lmt",
|
||||
"type": "amount"
|
||||
}
|
||||
]
|
||||
44
lsfx-mock-server/config/credit_response_examples.json
Normal file
44
lsfx-mock-server/config/credit_response_examples.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"success": {
|
||||
"message": "成功",
|
||||
"payload": {},
|
||||
"status_code": "0"
|
||||
},
|
||||
"errors": {
|
||||
"ERR_99999": {
|
||||
"message": "关键参数缺失,参数名: XX",
|
||||
"payload": null,
|
||||
"status_code": "ERR_99999"
|
||||
},
|
||||
"ERR_10001": {
|
||||
"message": "无效的证件号码",
|
||||
"payload": null,
|
||||
"status_code": "ERR_10001"
|
||||
},
|
||||
"ERR_10002": {
|
||||
"message": "无效的主题域",
|
||||
"payload": null,
|
||||
"status_code": "ERR_10002"
|
||||
},
|
||||
"ERR_10003": {
|
||||
"message": "报文类型无效,仅支持JSON/XML",
|
||||
"payload": null,
|
||||
"status_code": "ERR_10003"
|
||||
},
|
||||
"ERR_10004": {
|
||||
"message": "无效机构号或行社号",
|
||||
"payload": null,
|
||||
"status_code": "ERR_10004"
|
||||
},
|
||||
"ERR_10005": {
|
||||
"message": "无权限访问",
|
||||
"payload": null,
|
||||
"status_code": "ERR_10005"
|
||||
},
|
||||
"ERR_10006": {
|
||||
"message": "尽调报告生成异常:异步事件发送失败",
|
||||
"payload": null,
|
||||
"status_code": "ERR_10006"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import argparse
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from routers import api
|
||||
from routers import api, credit_api
|
||||
from config.settings import settings
|
||||
|
||||
# 创建 FastAPI 应用实例
|
||||
@@ -16,7 +16,7 @@ app = FastAPI(
|
||||
description="""
|
||||
## 流水分析 Mock 服务器
|
||||
|
||||
模拟流水分析平台的 7 个核心接口,用于开发和测试。
|
||||
模拟流水分析平台的 7 个核心接口,并补充征信解析接口,用于开发和测试。
|
||||
|
||||
### 主要功能
|
||||
|
||||
@@ -26,6 +26,7 @@ app = FastAPI(
|
||||
- **解析状态** - 轮询检查文件解析状态
|
||||
- **文件删除** - 批量删除上传的文件
|
||||
- **流水查询** - 分页获取银行流水数据
|
||||
- **征信解析** - 上传 HTML 并返回结构化征信 payload
|
||||
|
||||
### 错误模拟
|
||||
|
||||
@@ -39,6 +40,7 @@ app = FastAPI(
|
||||
2. 上传文件: POST /watson/api/project/remoteUploadSplitFile
|
||||
3. 轮询解析状态: POST /watson/api/project/upload/getpendings
|
||||
4. 获取流水: POST /watson/api/project/getBSByLogId
|
||||
5. 征信解析: POST /xfeature-mngs/conversation/htmlEval
|
||||
""",
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/docs",
|
||||
@@ -47,6 +49,7 @@ app = FastAPI(
|
||||
|
||||
# 包含 API 路由
|
||||
app.include_router(api.router, tags=["流水分析接口"])
|
||||
app.include_router(credit_api.router, tags=["征信解析接口"])
|
||||
|
||||
|
||||
@app.get("/", summary="服务根路径")
|
||||
|
||||
37
lsfx-mock-server/routers/credit_api.py
Normal file
37
lsfx-mock-server/routers/credit_api.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
|
||||
from services.credit_debug_service import CreditDebugService
|
||||
from services.credit_payload_service import CreditPayloadService
|
||||
|
||||
router = APIRouter()
|
||||
payload_service = CreditPayloadService("config/credit_feature_schema.json")
|
||||
debug_service = CreditDebugService("config/credit_response_examples.json")
|
||||
|
||||
|
||||
@router.post("/xfeature-mngs/conversation/htmlEval")
|
||||
async def html_eval(
|
||||
model: Optional[str] = Form(None),
|
||||
hType: Optional[str] = Form(None),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
):
|
||||
error_response = debug_service.validate_request(
|
||||
model=model,
|
||||
h_type=hType,
|
||||
file_present=file is not None,
|
||||
)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
payload = payload_service.generate_payload(
|
||||
model=model,
|
||||
h_type=hType,
|
||||
filename=file.filename or "credit.html",
|
||||
)
|
||||
return debug_service.build_success_response(payload)
|
||||
|
||||
|
||||
@router.get("/credit/health")
|
||||
async def credit_health():
|
||||
return {"status": "healthy", "service": "credit-mock"}
|
||||
62
lsfx-mock-server/services/credit_debug_service.py
Normal file
62
lsfx-mock-server/services/credit_debug_service.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CreditDebugService:
|
||||
"""处理征信解析接口的调试标记、参数校验与响应封装。"""
|
||||
|
||||
VALID_MODEL = "LXCUSTALL"
|
||||
VALID_HTYPES = {"PERSON", "ENTERPRISE"}
|
||||
|
||||
def __init__(self, template_path: str):
|
||||
self.template_path = template_path
|
||||
self.templates = self._load_templates()
|
||||
|
||||
def validate_request(self, model: Optional[str], h_type: Optional[str], file_present: bool):
|
||||
if not model:
|
||||
return self.build_missing_param_response("model")
|
||||
if not file_present:
|
||||
return self.build_missing_param_response("file")
|
||||
if not h_type:
|
||||
return self.build_missing_param_response("hType")
|
||||
|
||||
error_code = self.detect_error_marker(model)
|
||||
if error_code:
|
||||
return self.build_error_response(error_code)
|
||||
|
||||
if model != self.VALID_MODEL:
|
||||
return self.build_error_response("ERR_10002")
|
||||
if h_type not in self.VALID_HTYPES:
|
||||
return self.build_error_response("ERR_10003")
|
||||
return None
|
||||
|
||||
def build_success_response(self, payload: dict) -> dict:
|
||||
response = copy.deepcopy(self.templates["success"])
|
||||
response["payload"] = payload
|
||||
return response
|
||||
|
||||
def build_missing_param_response(self, param_name: str) -> dict:
|
||||
response = self.build_error_response("ERR_99999")
|
||||
response["message"] = response["message"].replace("XX", param_name)
|
||||
return response
|
||||
|
||||
def build_error_response(self, error_code: str) -> dict:
|
||||
return copy.deepcopy(self.templates["errors"][error_code])
|
||||
|
||||
def detect_error_marker(self, model: str) -> Optional[str]:
|
||||
matched = re.search(r"error_(ERR_\d+)", model)
|
||||
if not matched:
|
||||
return None
|
||||
error_code = matched.group(1)
|
||||
if error_code in self.templates["errors"]:
|
||||
return error_code
|
||||
return None
|
||||
|
||||
def _load_templates(self) -> dict:
|
||||
template_file = Path(self.template_path)
|
||||
if not template_file.is_absolute():
|
||||
template_file = Path(__file__).resolve().parent.parent / template_file
|
||||
return json.loads(template_file.read_text(encoding="utf-8"))
|
||||
86
lsfx-mock-server/services/credit_payload_service.py
Normal file
86
lsfx-mock-server/services/credit_payload_service.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class CreditPayloadService:
|
||||
"""根据征信字段 schema 生成稳定随机的 mock payload。"""
|
||||
|
||||
def __init__(self, schema_path: str):
|
||||
self.schema_path = schema_path
|
||||
self.schema = self._load_schema()
|
||||
|
||||
def generate_payload(self, model: str, h_type: str, filename: str) -> dict:
|
||||
rng = random.Random(self._build_seed(model, h_type, filename))
|
||||
payload = {
|
||||
"lx_header": {},
|
||||
"lx_debt": {},
|
||||
"lx_publictype": {},
|
||||
}
|
||||
|
||||
for item in self.schema:
|
||||
domain = item["domain"]
|
||||
field = item["field"]
|
||||
field_type = item["type"]
|
||||
payload[domain][field] = self._generate_value(field, field_type, item, rng)
|
||||
|
||||
return payload
|
||||
|
||||
def _load_schema(self) -> List[dict]:
|
||||
schema_file = Path(self.schema_path)
|
||||
if not schema_file.is_absolute():
|
||||
schema_file = Path(__file__).resolve().parent.parent / schema_file
|
||||
return json.loads(schema_file.read_text(encoding="utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _build_seed(model: str, h_type: str, filename: str) -> str:
|
||||
return f"{model}|{h_type}|{filename}"
|
||||
|
||||
def _generate_value(
|
||||
self,
|
||||
field: str,
|
||||
field_type: str,
|
||||
item: dict,
|
||||
rng: random.Random,
|
||||
) -> str:
|
||||
if field_type == "string":
|
||||
return self._generate_string(field, rng)
|
||||
if field_type == "amount":
|
||||
return f"{rng.uniform(0, 500000):.2f}"
|
||||
if field_type == "count":
|
||||
return str(rng.randint(0, 20))
|
||||
if field_type == "status":
|
||||
return rng.choice(item["options"])
|
||||
raise ValueError(f"Unsupported field type: {field_type}")
|
||||
|
||||
def _generate_string(self, field: str, rng: random.Random) -> str:
|
||||
if field == "query_cert_no":
|
||||
return self._generate_cert_no(rng)
|
||||
if field == "query_cust_name":
|
||||
return self._generate_name(rng)
|
||||
if field == "report_time":
|
||||
return self._generate_report_date(rng)
|
||||
return f"mock_{rng.randint(1000, 9999)}"
|
||||
|
||||
@staticmethod
|
||||
def _generate_cert_no(rng: random.Random) -> str:
|
||||
area_code = "330781"
|
||||
start_date = date(1980, 1, 1)
|
||||
birthday = start_date + timedelta(days=rng.randint(0, 14000))
|
||||
sequence = f"{rng.randint(100, 999)}"
|
||||
check_code = rng.choice("0123456789X")
|
||||
return f"{area_code}{birthday.strftime('%Y%m%d')}{sequence}{check_code}"
|
||||
|
||||
@staticmethod
|
||||
def _generate_name(rng: random.Random) -> str:
|
||||
surnames = ["张", "王", "李", "赵", "陈", "刘", "周", "吴"]
|
||||
given_names = ["伟", "芳", "娜", "敏", "静", "磊", "洋", "婷", "超", "洁"]
|
||||
return f"{rng.choice(surnames)}{rng.choice(given_names)}{rng.choice(given_names)}"
|
||||
|
||||
@staticmethod
|
||||
def _generate_report_date(rng: random.Random) -> str:
|
||||
base_date = date(2024, 1, 1)
|
||||
report_date = base_date + timedelta(days=rng.randint(0, 365))
|
||||
return report_date.strftime("%Y-%m-%d")
|
||||
@@ -37,7 +37,21 @@ def reset_file_service_state():
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""创建测试客户端"""
|
||||
return TestClient(app)
|
||||
original_routes = list(app.router.routes)
|
||||
try:
|
||||
from routers import credit_api
|
||||
|
||||
if not any(route.path == "/xfeature-mngs/conversation/htmlEval" for route in app.routes):
|
||||
app.include_router(credit_api.router, tags=["征信解析接口"])
|
||||
app.openapi_schema = None
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
yield TestClient(app)
|
||||
finally:
|
||||
app.router.routes[:] = original_routes
|
||||
app.openapi_schema = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -68,3 +82,9 @@ def sample_inner_flow_request():
|
||||
"dataEndDateId": 20240131,
|
||||
"uploadUserId": 902001,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_credit_html_file():
|
||||
"""示例征信 HTML 文件。"""
|
||||
return ("credit.html", b"<html></html>", "text/html")
|
||||
|
||||
45
lsfx-mock-server/tests/test_credit_api.py
Normal file
45
lsfx-mock-server/tests/test_credit_api.py
Normal file
@@ -0,0 +1,45 @@
|
||||
def test_html_eval_should_return_credit_payload(client, sample_credit_html_file):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"model": "LXCUSTALL", "hType": "PERSON"},
|
||||
files={"file": sample_credit_html_file},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status_code"] == "0"
|
||||
assert data["message"] == "成功"
|
||||
assert "lx_header" in data["payload"]
|
||||
|
||||
|
||||
def test_html_eval_should_return_err_99999_for_missing_model(client, sample_credit_html_file):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"hType": "PERSON"},
|
||||
files={"file": sample_credit_html_file},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status_code"] == "ERR_99999"
|
||||
|
||||
|
||||
def test_html_eval_should_return_err_10003_for_invalid_h_type(client, sample_credit_html_file):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"model": "LXCUSTALL", "hType": "JSON"},
|
||||
files={"file": sample_credit_html_file},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status_code"] == "ERR_10003"
|
||||
|
||||
|
||||
def test_html_eval_should_support_debug_error_marker(client, sample_credit_html_file):
|
||||
response = client.post(
|
||||
"/xfeature-mngs/conversation/htmlEval",
|
||||
data={"model": "error_ERR_10001", "hType": "PERSON"},
|
||||
files={"file": sample_credit_html_file},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status_code"] == "ERR_10001"
|
||||
35
lsfx-mock-server/tests/test_credit_payload_service.py
Normal file
35
lsfx-mock-server/tests/test_credit_payload_service.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from services.credit_payload_service import CreditPayloadService
|
||||
|
||||
|
||||
def test_generate_payload_should_be_stable_for_same_input():
|
||||
service = CreditPayloadService("config/credit_feature_schema.json")
|
||||
|
||||
payload1 = service.generate_payload(
|
||||
model="LXCUSTALL",
|
||||
h_type="PERSON",
|
||||
filename="credit-report-a.html",
|
||||
)
|
||||
payload2 = service.generate_payload(
|
||||
model="LXCUSTALL",
|
||||
h_type="PERSON",
|
||||
filename="credit-report-a.html",
|
||||
)
|
||||
|
||||
assert payload1 == payload2
|
||||
assert set(payload1.keys()) == {"lx_header", "lx_debt", "lx_publictype"}
|
||||
assert len(payload1["lx_debt"]) == 21
|
||||
assert len(payload1["lx_publictype"]) == 6
|
||||
|
||||
|
||||
def test_generate_payload_should_use_schema_type_rules():
|
||||
service = CreditPayloadService("config/credit_feature_schema.json")
|
||||
|
||||
payload = service.generate_payload(
|
||||
model="LXCUSTALL",
|
||||
h_type="ENTERPRISE",
|
||||
filename="credit-report-b.html",
|
||||
)
|
||||
|
||||
assert payload["lx_debt"]["uncle_bank_house_state"] in {"正常", "逾期", "不良"}
|
||||
assert payload["lx_header"]["report_time"].count("-") == 2
|
||||
assert payload["lx_publictype"]["civil_cnt"].isdigit()
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from main import app
|
||||
from main import parse_args as parse_main_args
|
||||
from dev import parse_args as parse_dev_args
|
||||
|
||||
@@ -17,3 +18,10 @@ def test_main_parse_args_should_accept_all_mode():
|
||||
def test_dev_parse_args_should_reject_invalid_mode():
|
||||
with pytest.raises(SystemExit):
|
||||
parse_dev_args(["--rule-hit-mode", "invalid"])
|
||||
|
||||
|
||||
def test_app_should_register_credit_mock_routes():
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/xfeature-mngs/conversation/htmlEval" in paths
|
||||
assert "/credit/health" in paths
|
||||
|
||||
@@ -141,4 +141,8 @@ lsfx:
|
||||
# 连接池配置
|
||||
pool:
|
||||
max-total: 100 # 最大连接数
|
||||
default-max-per-route: 20 # 每个路由最大连接数
|
||||
default-max-per-route: 20 # 每个路由最大连接数
|
||||
|
||||
credit-parse:
|
||||
api:
|
||||
url: http://localhost:8000/xfeature-mngs/conversation/htmlEval
|
||||
|
||||
@@ -112,7 +112,7 @@ spring:
|
||||
lsfx:
|
||||
api:
|
||||
# Mock Server(本地测试)
|
||||
base-url: http://116.62.17.81:62320
|
||||
base-url: http://192.168.0.111:62320
|
||||
# 测试环境
|
||||
# base-url: http://158.234.196.5:82/c4c3
|
||||
# 生产环境
|
||||
@@ -141,4 +141,8 @@ lsfx:
|
||||
# 连接池配置
|
||||
pool:
|
||||
max-total: 100 # 最大连接数
|
||||
default-max-per-route: 20 # 每个路由最大连接数
|
||||
default-max-per-route: 20 # 每个路由最大连接数
|
||||
|
||||
credit-parse:
|
||||
api:
|
||||
url: http://192.168.0.111:62320/xfeature-mngs/conversation/htmlEval
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- 个人类型专属字段 -->
|
||||
<template v-if="detailData.intermediaryType === '1'">
|
||||
<el-descriptions-item label="人员类型">{{ detailData.personType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="人员子类型">{{ detailData.personSubType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="中介子类型">{{ detailData.personSubType || detailData.relationType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">
|
||||
<span v-if="detailData.gender === 'M'">男</span>
|
||||
<span v-else-if="detailData.gender === 'F'">女</span>
|
||||
@@ -30,7 +30,6 @@
|
||||
<el-descriptions-item label="所在公司">{{ detailData.company || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职位">{{ detailData.position || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="企业统一信用码">{{ detailData.socialCreditCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关系类型">{{ detailData.relationType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关联人员ID">{{ detailData.relatedNumId || '-' }}</el-descriptions-item>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -62,8 +62,21 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="人员子类型">
|
||||
<el-input v-model="form.personSubType" placeholder="请输入人员子类型" maxlength="100" clearable/>
|
||||
<el-form-item label="中介子类型">
|
||||
<el-select
|
||||
v-model="form.personSubType"
|
||||
placeholder="请选择中介子类型"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handlePersonSubTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in personSubTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -132,20 +145,6 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="关联关系">
|
||||
<el-select v-model="form.relationType" placeholder="请选择关联关系" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in relationTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
@@ -409,6 +408,21 @@ export default {
|
||||
return '机构信息';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
personSubTypeOptions() {
|
||||
const options = [{ label: '个人', value: '个人' }, ...this.relationTypeOptions];
|
||||
const dedupedOptions = [];
|
||||
const existedValues = new Set();
|
||||
|
||||
options.forEach(item => {
|
||||
if (!item || !item.value || existedValues.has(item.value)) {
|
||||
return;
|
||||
}
|
||||
existedValues.add(item.value);
|
||||
dedupedOptions.push(item);
|
||||
});
|
||||
|
||||
return dedupedOptions;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -446,6 +460,8 @@ export default {
|
||||
}
|
||||
this.shouldAnimate = false;
|
||||
}
|
||||
|
||||
this.syncPersonSubTypeRelation();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -454,6 +470,9 @@ export default {
|
||||
handleTypeSelect(type) {
|
||||
this.tempSelectedType = type;
|
||||
this.form.intermediaryType = type;
|
||||
if (type === '1') {
|
||||
this.syncPersonSubTypeRelation();
|
||||
}
|
||||
// 延迟设置 selectedType,使表单显示带动画效果
|
||||
setTimeout(() => {
|
||||
this.selectedType = type;
|
||||
@@ -478,6 +497,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.form.intermediaryType === '1') {
|
||||
this.syncPersonSubTypeRelation();
|
||||
}
|
||||
|
||||
// 根据类型验证不同的表单
|
||||
const formRef = this.form.intermediaryType === '1' ? 'indivForm' : 'corpForm';
|
||||
|
||||
@@ -491,6 +514,19 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
handlePersonSubTypeChange(value) {
|
||||
this.form.relationType = value || null;
|
||||
},
|
||||
|
||||
syncPersonSubTypeRelation() {
|
||||
if (!this.form || this.form.intermediaryType !== '1') {
|
||||
return;
|
||||
}
|
||||
const currentValue = this.form.personSubType || this.form.relationType || null;
|
||||
this.form.personSubType = currentValue;
|
||||
this.form.relationType = currentValue;
|
||||
},
|
||||
|
||||
/**
|
||||
* 滚动到第一个错误字段
|
||||
*/
|
||||
|
||||
55
ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js
Normal file
55
ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const editDialogPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiIntermediary/components/EditDialog.vue"
|
||||
);
|
||||
const detailDialogPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiIntermediary/components/DetailDialog.vue"
|
||||
);
|
||||
|
||||
const editDialogSource = fs.readFileSync(editDialogPath, "utf8");
|
||||
const detailDialogSource = fs.readFileSync(detailDialogPath, "utf8");
|
||||
|
||||
[
|
||||
'label="中介子类型"',
|
||||
'v-model="form.personSubType"',
|
||||
'v-for="item in personSubTypeOptions"',
|
||||
"label: '个人'",
|
||||
"handlePersonSubTypeChange"
|
||||
].forEach((token) => {
|
||||
assert(
|
||||
editDialogSource.includes(token),
|
||||
`个人中介编辑弹窗缺少中介子类型改造: ${token}`
|
||||
);
|
||||
});
|
||||
|
||||
[
|
||||
'label="关联关系"',
|
||||
'v-model="form.relationType" placeholder="请选择关联关系"'
|
||||
].forEach((token) => {
|
||||
assert(
|
||||
!editDialogSource.includes(token),
|
||||
`个人中介编辑弹窗不应继续单独展示关联关系字段: ${token}`
|
||||
);
|
||||
});
|
||||
|
||||
[
|
||||
'label="中介子类型"',
|
||||
"detailData.personSubType || detailData.relationType || '-'"
|
||||
].forEach((token) => {
|
||||
assert(
|
||||
detailDialogSource.includes(token),
|
||||
`个人中介详情缺少中介子类型展示调整: ${token}`
|
||||
);
|
||||
});
|
||||
|
||||
assert(
|
||||
!detailDialogSource.includes('label="关系类型"'),
|
||||
"个人中介详情不应继续展示单独的关系类型字段"
|
||||
);
|
||||
|
||||
console.log("intermediary-person-edit-ui test passed");
|
||||
Reference in New Issue
Block a user