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