Compare commits

...

16 Commits

36 changed files with 3314 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, '') &lt;&gt; ''
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, '') &lt;&gt; ''
@@ -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

View File

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

View 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` 完全隔离”的方式,以最短路径补齐征信解析调用能力,同时保证与现有银行流水分析链路边界清晰,符合当前需求边界。

View 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 解析和过度设计,符合最短路径实现要求。

View File

@@ -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` 技能中的子代理审阅环节。
- 实施时如果发现外部接口真实返回结构与说明书不一致,应先修正响应映射,再更新实施记录,不要额外扩展业务逻辑。

View File

@@ -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`
- 主应用已注册征信解析 routerREADME 与运行方式保持一致
- [ ] **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后端实施与验证记录"
```

View File

@@ -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本计划不执行子代理审阅环节。

View File

@@ -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前端核验记录"
```

View File

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

View File

@@ -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 解析。
- 未新增设计文档之外的征信接口。
- 未引入运行时动态配置或管理页面。

View File

@@ -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`:构建通过,仅存在项目原有的产物体积告警,无新增编译错误。

View File

@@ -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`
- 新增回归测试已覆盖采购规则项目范围约束,防止后续再次把全局采购员工带入单个项目的预警统计。

View File

@@ -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` 的情况,但不影响当前实现正确性;本次已通过切换到空闲端口完成隔离验证。

View File

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

View 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"
}
]

View 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"
}
}
}

View File

@@ -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="服务根路径")

View 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"}

View 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"))

View 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")

View File

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

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

View 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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
},
/**
* 滚动到第一个错误字段
*/

View 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");