diff --git a/ccdi-lsfx/pom.xml b/ccdi-lsfx/pom.xml
index bf3f3ed3..2fac92c9 100644
--- a/ccdi-lsfx/pom.xml
+++ b/ccdi-lsfx/pom.xml
@@ -44,5 +44,11 @@
org.springdoc
springdoc-openapi-starter-webmvc-ui
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java
new file mode 100644
index 00000000..c03be8c5
--- /dev/null
+++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java
@@ -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 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);
+ }
+ }
+}
diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java
new file mode 100644
index 00000000..b40e58a0
--- /dev/null
+++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java
@@ -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) {
+ // 忽略临时文件删除失败,避免影响主流程返回
+ }
+ }
+ }
+ }
+}
diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java
new file mode 100644
index 00000000..c044690d
--- /dev/null
+++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParsePayload.java
@@ -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 lxHeader;
+
+ @JsonProperty("lx_debt")
+ private Map lxDebt;
+
+ @JsonProperty("lx_publictype")
+ private Map lxPublictype;
+}
diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java
new file mode 100644
index 00000000..39b0b557
--- /dev/null
+++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/CreditParseResponse.java
@@ -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;
+}
diff --git a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java
new file mode 100644
index 00000000..99da5353
--- /dev/null
+++ b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/CreditParseClientTest.java
@@ -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")));
+ }
+}
diff --git a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java
new file mode 100644
index 00000000..8fe69e9a
--- /dev/null
+++ b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java
@@ -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", "".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", "".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"));
+ }
+}
diff --git a/docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md b/docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md
new file mode 100644
index 00000000..2cc32249
--- /dev/null
+++ b/docs/reports/implementation/2026-03-23-credit-parse-client-implementation.md
@@ -0,0 +1,80 @@
+# 征信解析客户端实施记录
+
+## 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 暴露和请求链路已打通;外部征信解析服务当前未响应,需待对方服务可用后继续做成功结果验证。
diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml
index 8653fd89..9435c784 100644
--- a/ruoyi-admin/src/main/resources/application-dev.yml
+++ b/ruoyi-admin/src/main/resources/application-dev.yml
@@ -141,4 +141,8 @@ lsfx:
# 连接池配置
pool:
max-total: 100 # 最大连接数
- default-max-per-route: 20 # 每个路由最大连接数
\ No newline at end of file
+ default-max-per-route: 20 # 每个路由最大连接数
+
+credit-parse:
+ api:
+ url: http://64.202.94.120:8081/xfeature-mngs/conversation/htmlEval
diff --git a/ruoyi-admin/src/main/resources/application-nas.yml b/ruoyi-admin/src/main/resources/application-nas.yml
index 0c03d334..d75193a7 100644
--- a/ruoyi-admin/src/main/resources/application-nas.yml
+++ b/ruoyi-admin/src/main/resources/application-nas.yml
@@ -141,4 +141,8 @@ lsfx:
# 连接池配置
pool:
max-total: 100 # 最大连接数
- default-max-per-route: 20 # 每个路由最大连接数
\ No newline at end of file
+ default-max-per-route: 20 # 每个路由最大连接数
+
+credit-parse:
+ api:
+ url: http://64.202.94.120:8081/xfeature-mngs/conversation/htmlEval