Compare commits
9 Commits
codex/pull
...
ffaa2fb2a0
| Author | SHA1 | Date | |
|---|---|---|---|
| ffaa2fb2a0 | |||
| dc8055ce7a | |||
| 90f483f427 | |||
| 2d9828ea5f | |||
| 0a0313af7c | |||
| 175a50bf96 | |||
| 7fec8e21be | |||
| 17628486ae | |||
| dd424cca50 |
84
AGENTS.md
84
AGENTS.md
@@ -1,44 +1,5 @@
|
||||
# AGENTS.md - AI Coding Assistant Guide
|
||||
|
||||
## 全局执行规则
|
||||
|
||||
### Git
|
||||
|
||||
- Git 提交时使用中文添加描述
|
||||
- 无视 `.DS_Store`
|
||||
|
||||
### AGENT
|
||||
|
||||
- `using-superpowers` 只有在用户明确声明调用时才允许启用;不要因为“会话开始”“任务较复杂”或“可能适用”而自动调用
|
||||
- 当用户没有明确声明 `using-superpowers` 时,按普通流程直接处理需求
|
||||
- 默认不开启 subagent
|
||||
- 如用户明确要求启用 subagent,所有 subagent 必须使用 `gpt-5.5`;默认推理强度为高,审查类 subagent 的推理强度为超高,测试类 subagent 的推理强度为中
|
||||
|
||||
### 文档
|
||||
|
||||
- 写完的设计文档必须要由审查类子代理进行内容审查,确保方案与实施方法符合实际需求,并检查是否还有需要明确的功能点
|
||||
- 当功能设计涉及到前端和后端都有改动时,输出两份执行文档,一份为后端的实施计划,一份为前端的实施计划。如果不是前后端架构的项目不需要输出两份执行文档
|
||||
- 当功能修改只涉及到前端或只涉及到后端,只需要输出对应的实施计划
|
||||
- 每一次改动都需要留下实施文档,记录修改的内容
|
||||
- 每次写设计文档的时候,都要检查一下保存路径是否正确
|
||||
|
||||
### 测试
|
||||
|
||||
- 测试结束后,自动结束测试时开启的前后端进程
|
||||
- 前端 Node 需要使用 nvm 进行控制版本
|
||||
- 在完成页面功能开发后,必须使用 `browser-use` 技能打开浏览器进行实际页面测试,禁止打开 prototype 原型页面进行测试
|
||||
- 所有生成的测试文件不需要上传到 Git
|
||||
|
||||
### 方案规范
|
||||
|
||||
当需要给出方案时必须符合以下规范:
|
||||
|
||||
- 不允许给出兼容性或补丁性的方案,不允许过度设计,保持最短路径实现且不能违反第一条要求
|
||||
- 不允许自行给出用户提供的需求以外的方案,例如一些兜底和降级方案,这可能导致业务逻辑偏移问题
|
||||
- 必须确保方案的逻辑正确,必须经过全链路的逻辑验证
|
||||
|
||||
---
|
||||
|
||||
## 项目概述
|
||||
|
||||
本仓库是纪检初核系统主仓库,基于若依 `v3.9.1`,当前技术栈以 `Java 21 + Spring Boot 3 + Vue 2` 为主,并包含独立的流水分析 Mock 服务、Docker 部署文件、SQL 脚本、实施文档与测试文档。
|
||||
@@ -54,52 +15,26 @@
|
||||
|
||||
---
|
||||
|
||||
## 高优先级规则
|
||||
|
||||
- 使用简体中文进行思考和对话
|
||||
- Git 提交说明必须使用中文
|
||||
- 忽略 `.DS_Store` 文件,不将其视为本次任务需要处理或提交的有效变更
|
||||
- 仅当用户明确声明调用 `using-superpowers` 时才允许启用;未明确声明时按普通流程直接处理需求
|
||||
- Git 提交前必须检查暂存区,仅允许包含本次任务相关文件;若存在无关文件,必须先移出暂存或与用户确认
|
||||
- 每一次改动都需要留下实施文档,记录修改内容、影响范围与验证情况
|
||||
- 功能设计同时涉及前端和后端改动时,必须分别输出后端与前端两份实施计划;若仅涉及单侧,则只输出对应实施计划
|
||||
- 新增或修改设计文档、实施计划、实施记录前,必须先确认保存路径是否正确
|
||||
- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本
|
||||
- 测试结束后,自动关闭测试过程中启动的前后端进程
|
||||
- 重启后端时,必须优先使用 `bin/restart_java_backend.sh`
|
||||
- 禁止在前端源码、配置、示例数据或页面默认值中硬编码或预填真实账号密码;登录页不得将密码保存到 Cookie、localStorage 或 sessionStorage
|
||||
|
||||
---
|
||||
|
||||
## 协作约定
|
||||
|
||||
### 基础协作
|
||||
|
||||
- 使用简体中文进行思考和对话
|
||||
- 前端开发直接在当前分支进行,不需要额外创建 git worktree
|
||||
- 给出方案时,必须保持最短路径实现,不允许提供兼容性、补丁性或过度设计的方案
|
||||
- 不允许自行扩展出用户需求之外的兜底、降级或变体方案,避免业务逻辑偏移
|
||||
- 输出方案前必须完成全链路逻辑校验,确保方案逻辑正确、链路闭环
|
||||
|
||||
### Git 与变更管理
|
||||
|
||||
- Git 提交前必须检查暂存区,仅保留本次任务相关文件
|
||||
- 若暂存区存在无关文件,必须先移出暂存或与用户确认,禁止顺带提交
|
||||
- `.DS_Store` 默认忽略,不纳入任务变更范围
|
||||
- Git 提交前必须检查暂存区,仅保留本次任务相关文件;若存在无关文件,必须先移出暂存或与用户确认,禁止顺带提交
|
||||
|
||||
### 文档产出
|
||||
|
||||
- 若需求来自设计文档,默认同时沉淀后端与前端两份实施计划
|
||||
- 功能设计同时涉及前端和后端改动时,实施计划分别放在 `docs/plans/backend/` 与 `docs/plans/frontend/`
|
||||
- 功能修改只涉及前端或只涉及后端时,只输出对应的实施计划
|
||||
- 非前后端架构项目不强制拆分两份实施计划
|
||||
- 每一次改动都需要留下实施文档,实施记录优先放在 `docs/reports/implementation/`
|
||||
- 每次新增或修改设计文档、实施计划、实施记录前,都要先确认保存路径是否正确
|
||||
- 后端实施计划放在 `docs/plans/backend/`,前端实施计划放在 `docs/plans/frontend/`
|
||||
- 综合实施计划、联调计划和通用实施文档放在 `docs/plans/fullstack/`
|
||||
- 每一次改动都需要留下实施文档,记录修改内容、影响范围与验证情况;实施记录优先放在 `docs/reports/implementation/`
|
||||
|
||||
### 测试与运行
|
||||
|
||||
- 测试结束后,自动关闭测试过程中启动的前后端进程
|
||||
- 重启后端时,必须优先使用 `bin/restart_java_backend.sh`,不要直接手工执行 `java -jar` 替代正式重启流程
|
||||
- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本
|
||||
|
||||
### 数据库与编码
|
||||
|
||||
@@ -258,7 +193,8 @@ return AjaxResult.success(result);
|
||||
- 请求统一使用 `@/utils/request`
|
||||
- 新增页面或功能入口时,同步检查 `sys_menu`、路由、权限标识
|
||||
- 优先延续现有 `ccdi*` 业务目录与命名方式,不随意新造平行目录
|
||||
- 登录页只能在用户主动选择时保存用户名,不允许保存密码或预填默认密码
|
||||
- 禁止在前端源码、配置、示例数据或页面默认值中硬编码或预填真实账号密码
|
||||
- 登录页只能在用户主动选择时保存用户名,不允许将密码保存到 Cookie、localStorage 或 sessionStorage,也不允许预填默认密码
|
||||
|
||||
### 导入功能规范
|
||||
|
||||
@@ -386,10 +322,6 @@ ccdi/
|
||||
- 新增文档优先落到 `docs/` 下的规范目录,不要继续堆放到 `docs/plans/` 根目录
|
||||
- 只有历史资料或外部原始材料才放入 `assets/`
|
||||
- 如果移动了文档,需同步修正文档内引用路径
|
||||
- 若需求来自设计文档,默认同时沉淀后端与前端两份实施计划
|
||||
- 功能设计同时涉及前端和后端改动时,必须分别输出后端与前端两份实施计划;若仅涉及前端或仅涉及后端,则只输出对应实施计划;非前后端架构项目不强制拆分双文档
|
||||
- 每一次改动都需要留下实施文档,记录本次修改内容、影响范围与验证情况,实施记录优先放在 `docs/reports/implementation/`
|
||||
- 每次新增或修改设计文档、实施计划、实施记录前,都要先确认保存路径是否正确
|
||||
|
||||
---
|
||||
|
||||
@@ -399,5 +331,3 @@ ccdi/
|
||||
- `lsfx-mock-server` 是独立子项目,除非任务涉及流水分析联调,否则不要把其实现规则混入主 Java 工程
|
||||
- `docker/backend`、`docker/frontend`、`docker/mock` 分别对应三类运行时镜像
|
||||
- `sql/migration/` 用于增量迁移脚本,新增修复脚本优先按日期或功能命名
|
||||
- 启动前后端或 Mock 服务做验证后,结束测试时要主动停止进程,避免残留占用端口
|
||||
- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.info.collection.domain.dto.CcdiCreditInfoQueryDTO;
|
||||
import com.ruoyi.info.collection.domain.vo.CreditInfoListVO;
|
||||
import com.ruoyi.info.collection.service.ICcdiCreditInfoService;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -42,7 +44,8 @@ public class CcdiCreditInfoController extends BaseController {
|
||||
@Log(title = "征信维护", businessType = BusinessType.IMPORT)
|
||||
@PostMapping("/upload")
|
||||
public AjaxResult upload(@RequestParam("files") MultipartFile[] files) {
|
||||
return AjaxResult.success("上传成功", creditInfoService.upload(Arrays.asList(files)));
|
||||
CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser());
|
||||
return AjaxResult.success("上传成功", creditInfoService.upload(Arrays.asList(files), caller));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询征信维护列表")
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiCreditInfoQueryDTO;
|
||||
import com.ruoyi.info.collection.domain.vo.CreditInfoDetailVO;
|
||||
import com.ruoyi.info.collection.domain.vo.CreditInfoListVO;
|
||||
import com.ruoyi.info.collection.domain.vo.CreditInfoUploadResultVO;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
@@ -16,7 +17,7 @@ import java.util.List;
|
||||
*/
|
||||
public interface ICcdiCreditInfoService {
|
||||
|
||||
CreditInfoUploadResultVO upload(List<MultipartFile> files);
|
||||
CreditInfoUploadResultVO upload(List<MultipartFile> files, CallerContext caller);
|
||||
|
||||
Page<CreditInfoListVO> selectCreditInfoPage(Page<CreditInfoListVO> page, CcdiCreditInfoQueryDTO queryDTO);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.ruoyi.info.collection.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.info.collection.domain.CcdiCreditNegativeInfo;
|
||||
import com.ruoyi.info.collection.domain.CcdiDebtsInfo;
|
||||
import com.ruoyi.info.collection.domain.dto.CcdiCreditInfoQueryDTO;
|
||||
@@ -17,10 +16,12 @@ import com.ruoyi.info.collection.service.ICcdiCreditInfoService;
|
||||
import com.ruoyi.info.collection.service.support.CreditHtmlStorageService;
|
||||
import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler;
|
||||
import com.ruoyi.lsfx.client.CreditParseClient;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParsePayload;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -33,6 +34,7 @@ import java.util.Map;
|
||||
/**
|
||||
* 征信维护服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
|
||||
|
||||
@@ -59,12 +61,12 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
|
||||
private CcdiCreditInfoQueryMapper queryMapper;
|
||||
|
||||
@Override
|
||||
public CreditInfoUploadResultVO upload(List<MultipartFile> files) {
|
||||
public CreditInfoUploadResultVO upload(List<MultipartFile> files, CallerContext caller) {
|
||||
CreditInfoUploadResultVO result = new CreditInfoUploadResultVO();
|
||||
List<CreditInfoUploadFailureVO> failures = new ArrayList<>();
|
||||
int totalCount = files == null ? 0 : files.size();
|
||||
int successCount = 0;
|
||||
String userName = currentUserName();
|
||||
String userName = caller.username();
|
||||
|
||||
if (files == null || files.isEmpty()) {
|
||||
result.setTotalCount(0);
|
||||
@@ -77,7 +79,7 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
|
||||
for (MultipartFile file : files) {
|
||||
try {
|
||||
validateHtmlFile(file);
|
||||
handleSingleFile(file, userName);
|
||||
handleSingleFile(file, userName, caller);
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
failures.add(buildFailure(file, null, null, e.getMessage()));
|
||||
@@ -148,9 +150,9 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSingleFile(MultipartFile multipartFile, String userName) throws Exception {
|
||||
private void handleSingleFile(MultipartFile multipartFile, String userName, CallerContext caller) throws Exception {
|
||||
CreditHtmlStorageService.StoredCreditHtml storedHtml = creditHtmlStorageService.save(multipartFile);
|
||||
CreditParseInvokeResponse response = creditParseClient.parse(storedHtml.remotePath());
|
||||
CreditParseInvokeResponse response = parseAndDeleteLocalFile(caller, storedHtml);
|
||||
CreditParsePayload payload = requireResponse(response).getPayload();
|
||||
Map<String, Object> header = requireHeader(payload);
|
||||
String personId = stringValue(header.get("query_cert_no"));
|
||||
@@ -164,6 +166,25 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
|
||||
replaceEmployeeCredit(personId, debts, negative, userName);
|
||||
}
|
||||
|
||||
private CreditParseInvokeResponse parseAndDeleteLocalFile(
|
||||
CallerContext caller, CreditHtmlStorageService.StoredCreditHtml storedHtml) throws Exception {
|
||||
CreditParseInvokeResponse response;
|
||||
try {
|
||||
response = creditParseClient.parse(caller, storedHtml.remotePath());
|
||||
} catch (Exception parseException) {
|
||||
try {
|
||||
creditHtmlStorageService.delete(storedHtml);
|
||||
} catch (Exception deleteException) {
|
||||
parseException.addSuppressed(deleteException);
|
||||
log.error("征信解析失败后清理本地文件失败", deleteException);
|
||||
}
|
||||
throw parseException;
|
||||
}
|
||||
|
||||
creditHtmlStorageService.delete(storedHtml);
|
||||
return response;
|
||||
}
|
||||
|
||||
private void validateHtmlFile(MultipartFile file) {
|
||||
String originalFilename = file == null ? null : file.getOriginalFilename();
|
||||
if (originalFilename == null) {
|
||||
@@ -286,11 +307,4 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
|
||||
return negativeVO;
|
||||
}
|
||||
|
||||
private String currentUserName() {
|
||||
try {
|
||||
return SecurityUtils.getUsername();
|
||||
} catch (Exception e) {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import org.springframework.stereotype.Component;
|
||||
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.Paths;
|
||||
|
||||
/**
|
||||
* 征信 HTML 服务器落盘与远程访问地址生成。
|
||||
@@ -16,6 +20,7 @@ import java.io.File;
|
||||
public class CreditHtmlStorageService {
|
||||
|
||||
private static final String CREDIT_HTML_DIR = "credit-html";
|
||||
private static final String CREDIT_HTML_PROFILE_PREFIX = "/profile/" + CREDIT_HTML_DIR + "/";
|
||||
private static final String[] HTML_EXTENSIONS = {"html", "htm"};
|
||||
|
||||
@Value("${credit-parse.api.file-public-base-url}")
|
||||
@@ -26,6 +31,25 @@ public class CreditHtmlStorageService {
|
||||
return new StoredCreditHtml(profilePath, buildRemotePath(profilePath));
|
||||
}
|
||||
|
||||
public void delete(StoredCreditHtml storedHtml) throws IOException {
|
||||
String profilePath = storedHtml == null ? null : storedHtml.profilePath();
|
||||
if (StringUtils.isBlank(profilePath) || !profilePath.startsWith(CREDIT_HTML_PROFILE_PREFIX)) {
|
||||
throw new IllegalArgumentException("征信HTML本地文件路径非法");
|
||||
}
|
||||
|
||||
String relativePath = profilePath.substring(CREDIT_HTML_PROFILE_PREFIX.length());
|
||||
if (StringUtils.isBlank(relativePath)) {
|
||||
throw new IllegalArgumentException("征信HTML本地文件路径非法");
|
||||
}
|
||||
|
||||
Path baseDir = Paths.get(getCreditHtmlBaseDir()).toAbsolutePath().normalize();
|
||||
Path targetFile = baseDir.resolve(relativePath).normalize();
|
||||
if (!targetFile.startsWith(baseDir)) {
|
||||
throw new IllegalArgumentException("征信HTML本地文件路径越界");
|
||||
}
|
||||
Files.deleteIfExists(targetFile);
|
||||
}
|
||||
|
||||
private String getCreditHtmlBaseDir() {
|
||||
return RuoYiConfig.getProfile() + File.separator + CREDIT_HTML_DIR;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.ruoyi.info.collection.service.impl.CcdiCreditInfoServiceImpl;
|
||||
import com.ruoyi.info.collection.service.support.CreditHtmlStorageService;
|
||||
import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler;
|
||||
import com.ruoyi.lsfx.client.CreditParseClient;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseInvokeData;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParsePayload;
|
||||
@@ -18,6 +19,7 @@ import com.ruoyi.lsfx.domain.response.CreditParseResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -34,15 +36,26 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CcdiCreditInfoServiceImplTest {
|
||||
|
||||
private static final CallerContext CALLER = CallerContext.of(7L, "tester");
|
||||
private static final CreditHtmlStorageService.StoredCreditHtml STORED_HTML =
|
||||
new CreditHtmlStorageService.StoredCreditHtml(
|
||||
"/profile/credit-html/2026/05/12/a_1.html",
|
||||
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html");
|
||||
|
||||
@InjectMocks
|
||||
private CcdiCreditInfoServiceImpl service;
|
||||
|
||||
@@ -73,19 +86,23 @@ class CcdiCreditInfoServiceImplTest {
|
||||
.thenReturn(new CreditHtmlStorageService.StoredCreditHtml(
|
||||
"/profile/credit-html/2026/05/12/family_1.html",
|
||||
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html"));
|
||||
when(creditParseClient.parse(anyString()))
|
||||
when(creditParseClient.parse(any(CallerContext.class), anyString()))
|
||||
.thenReturn(successResponse("330101199202020022", "李四", "2026-03-24"));
|
||||
when(assembler.buildDebts(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class)))
|
||||
.thenReturn(List.of(buildDebt("330101199202020022")));
|
||||
when(assembler.buildNegative(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class)))
|
||||
.thenReturn(buildNegative("330101199202020022"));
|
||||
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file));
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER);
|
||||
|
||||
assertEquals(1, result.getSuccessCount());
|
||||
assertEquals(0, result.getFailureCount());
|
||||
verify(creditParseClient).parse("http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html");
|
||||
verify(debtsInfoMapper).deleteByPersonId("330101199202020022");
|
||||
InOrder processingOrder = inOrder(creditParseClient, creditHtmlStorageService, debtsInfoMapper);
|
||||
processingOrder.verify(creditParseClient)
|
||||
.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html");
|
||||
processingOrder.verify(creditHtmlStorageService)
|
||||
.delete(any(CreditHtmlStorageService.StoredCreditHtml.class));
|
||||
processingOrder.verify(debtsInfoMapper).deleteByPersonId("330101199202020022");
|
||||
verify(negativeInfoMapper).deleteByPersonId("330101199202020022");
|
||||
}
|
||||
|
||||
@@ -97,15 +114,16 @@ class CcdiCreditInfoServiceImplTest {
|
||||
.thenReturn(new CreditHtmlStorageService.StoredCreditHtml(
|
||||
"/profile/credit-html/2026/05/12/a_1.html",
|
||||
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html"));
|
||||
when(creditParseClient.parse(anyString()))
|
||||
when(creditParseClient.parse(any(CallerContext.class), anyString()))
|
||||
.thenReturn(successResponse("330101199001010011", "张三", "2026-03-03"));
|
||||
when(queryMapper.selectLatestQueryDate("330101199001010011"))
|
||||
.thenReturn(LocalDate.parse("2026-03-05"));
|
||||
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file));
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER);
|
||||
|
||||
assertEquals(0, result.getSuccessCount());
|
||||
assertEquals("上传征信日期早于当前已维护最新记录", result.getFailures().get(0).getReason());
|
||||
verify(creditHtmlStorageService).delete(STORED_HTML);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -118,12 +136,13 @@ class CcdiCreditInfoServiceImplTest {
|
||||
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html"));
|
||||
CreditParseInvokeResponse response = successResponse("330101199001010011", "张三", "2026-03-03");
|
||||
response.setCode(99999);
|
||||
when(creditParseClient.parse(anyString())).thenReturn(response);
|
||||
when(creditParseClient.parse(any(CallerContext.class), anyString())).thenReturn(response);
|
||||
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file));
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER);
|
||||
|
||||
assertEquals(0, result.getSuccessCount());
|
||||
assertEquals("征信解析平台状态码异常: 99999", result.getFailures().get(0).getReason());
|
||||
verify(creditHtmlStorageService).delete(STORED_HTML);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -138,17 +157,83 @@ class CcdiCreditInfoServiceImplTest {
|
||||
response.getData().setStatus(0);
|
||||
response.getData().setReasonCode(500);
|
||||
response.getData().setReasonMessage("结果解析失败");
|
||||
when(creditParseClient.parse(anyString())).thenReturn(response);
|
||||
when(creditParseClient.parse(any(CallerContext.class), anyString())).thenReturn(response);
|
||||
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file));
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER);
|
||||
|
||||
assertEquals(0, result.getSuccessCount());
|
||||
assertEquals("结果解析失败", result.getFailures().get(0).getReason());
|
||||
verify(creditHtmlStorageService).delete(STORED_HTML);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadHtmlFiles_shouldDeleteBeforeDatabaseFailure() throws Exception {
|
||||
MockMultipartFile file = htmlFile("a.html");
|
||||
when(creditHtmlStorageService.save(any())).thenReturn(STORED_HTML);
|
||||
when(creditParseClient.parse(any(CallerContext.class), anyString()))
|
||||
.thenReturn(successResponse("330101199001010011", "张三", "2026-03-03"));
|
||||
when(assembler.buildDebts(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class)))
|
||||
.thenReturn(List.of(buildDebt("330101199001010011")));
|
||||
when(assembler.buildNegative(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class)))
|
||||
.thenReturn(buildNegative("330101199001010011"));
|
||||
doThrow(new RuntimeException("入库失败"))
|
||||
.when(debtsInfoMapper).deleteByPersonId("330101199001010011");
|
||||
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER);
|
||||
|
||||
assertEquals(0, result.getSuccessCount());
|
||||
assertEquals("入库失败", result.getFailures().get(0).getReason());
|
||||
InOrder processingOrder = inOrder(creditHtmlStorageService, debtsInfoMapper);
|
||||
processingOrder.verify(creditHtmlStorageService).delete(STORED_HTML);
|
||||
processingOrder.verify(debtsInfoMapper).deleteByPersonId("330101199001010011");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadHtmlFiles_shouldFailBeforeValidationWhenDeleteFails() throws Exception {
|
||||
MockMultipartFile file = htmlFile("a.html");
|
||||
when(creditHtmlStorageService.save(any())).thenReturn(STORED_HTML);
|
||||
when(creditParseClient.parse(any(CallerContext.class), anyString()))
|
||||
.thenReturn(successResponse("330101199001010011", "张三", "2026-03-03"));
|
||||
doThrow(new java.io.IOException("本地文件删除失败"))
|
||||
.when(creditHtmlStorageService).delete(STORED_HTML);
|
||||
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER);
|
||||
|
||||
assertEquals(0, result.getSuccessCount());
|
||||
assertEquals("本地文件删除失败", result.getFailures().get(0).getReason());
|
||||
verifyNoInteractions(assembler, debtsInfoMapper, negativeInfoMapper, queryMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadHtmlFiles_shouldPreserveParseErrorAndContinueWhenDeleteAlsoFails() throws Exception {
|
||||
MockMultipartFile failedFile = htmlFile("failed.html");
|
||||
MockMultipartFile successFile = htmlFile("success.html");
|
||||
CreditHtmlStorageService.StoredCreditHtml failedStoredHtml = storedHtml("failed_1.html");
|
||||
CreditHtmlStorageService.StoredCreditHtml successStoredHtml = storedHtml("success_1.html");
|
||||
when(creditHtmlStorageService.save(any())).thenReturn(failedStoredHtml, successStoredHtml);
|
||||
when(creditParseClient.parse(any(CallerContext.class), anyString()))
|
||||
.thenThrow(new RuntimeException("解析接口异常"))
|
||||
.thenReturn(successResponse("330101199001010011", "张三", "2026-03-03"));
|
||||
doThrow(new java.io.IOException("本地文件删除失败"))
|
||||
.doNothing()
|
||||
.when(creditHtmlStorageService).delete(any(CreditHtmlStorageService.StoredCreditHtml.class));
|
||||
when(assembler.buildDebts(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class)))
|
||||
.thenReturn(List.of());
|
||||
when(assembler.buildNegative(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class)))
|
||||
.thenReturn(null);
|
||||
|
||||
CreditInfoUploadResultVO result = service.upload(List.of(failedFile, successFile), CALLER);
|
||||
|
||||
assertEquals(1, result.getSuccessCount());
|
||||
assertEquals(1, result.getFailureCount());
|
||||
assertEquals("解析接口异常", result.getFailures().get(0).getReason());
|
||||
verify(creditHtmlStorageService).delete(failedStoredHtml);
|
||||
verify(creditHtmlStorageService).delete(successStoredHtml);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void creditHtmlStorage_shouldStoreHtmlUnderProfileAndBuildRemotePath(@TempDir Path profileDir) throws Exception {
|
||||
void creditHtmlStorage_shouldStoreAndDeleteHtmlWithinProfile(@TempDir Path profileDir) throws Exception {
|
||||
String oldProfile = RuoYiConfig.getProfile();
|
||||
new RuoYiConfig().setProfile(profileDir.toString());
|
||||
try {
|
||||
@@ -164,11 +249,42 @@ class CcdiCreditInfoServiceImplTest {
|
||||
assertEquals("http://127.0.0.1:62318" + storedHtml.profilePath(), storedHtml.remotePath());
|
||||
Path savedFile = profileDir.resolve(storedHtml.profilePath().substring("/profile/".length()));
|
||||
assertTrue(Files.exists(savedFile));
|
||||
storageService.delete(storedHtml);
|
||||
assertFalse(Files.exists(savedFile));
|
||||
storageService.delete(storedHtml);
|
||||
} finally {
|
||||
new RuoYiConfig().setProfile(oldProfile);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void creditHtmlStorage_shouldRejectPathOutsideCreditHtmlDirectory(@TempDir Path profileDir) throws Exception {
|
||||
String oldProfile = RuoYiConfig.getProfile();
|
||||
new RuoYiConfig().setProfile(profileDir.toString());
|
||||
try {
|
||||
CreditHtmlStorageService storageService = new CreditHtmlStorageService();
|
||||
Path outsideFile = Files.writeString(profileDir.resolve("outside.html"), "outside");
|
||||
CreditHtmlStorageService.StoredCreditHtml storedHtml = new CreditHtmlStorageService.StoredCreditHtml(
|
||||
"/profile/credit-html/../outside.html", "http://127.0.0.1/profile/credit-html/../outside.html");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> storageService.delete(storedHtml));
|
||||
assertTrue(Files.exists(outsideFile));
|
||||
} finally {
|
||||
new RuoYiConfig().setProfile(oldProfile);
|
||||
}
|
||||
}
|
||||
|
||||
private MockMultipartFile htmlFile(String fileName) {
|
||||
return new MockMultipartFile(
|
||||
"files", fileName, "text/html", "<html>ok</html>".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private CreditHtmlStorageService.StoredCreditHtml storedHtml(String fileName) {
|
||||
return new CreditHtmlStorageService.StoredCreditHtml(
|
||||
"/profile/credit-html/2026/05/12/" + fileName,
|
||||
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/" + fileName);
|
||||
}
|
||||
|
||||
private CreditParseInvokeResponse successResponse(String personId, String personName, String reportTime) {
|
||||
CreditParsePayload payload = new CreditParsePayload();
|
||||
Map<String, Object> header = new HashMap<>();
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 系统日志持久化 -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-system</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.lsfx.client;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import com.ruoyi.lsfx.util.HttpUtil;
|
||||
@@ -45,20 +46,20 @@ public class CreditParseClient {
|
||||
@Value("${credit-parse.api.model:LXCUSTALL}")
|
||||
private String defaultModel;
|
||||
|
||||
public CreditParseInvokeResponse parse(String remotePath) {
|
||||
return parse(defaultModel, remotePath);
|
||||
public CreditParseInvokeResponse parse(CallerContext caller, String remotePath) {
|
||||
return parse(caller, defaultModel, remotePath);
|
||||
}
|
||||
|
||||
public CreditParseInvokeResponse parse(String model, String remotePath) {
|
||||
public CreditParseInvokeResponse parse(CallerContext caller, String model, String remotePath) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
String actualModel = StringUtils.isBlank(model) ? defaultModel : model;
|
||||
String serialNum = buildSerialNum();
|
||||
try {
|
||||
Map<String, Object> initiateParams = buildInitiateParams(serialNum, actualModel, remotePath);
|
||||
CreditParseInvokeResponse initiateResponse = request(creditParseUrl, initiateParams, "发起接口");
|
||||
CreditParseInvokeResponse initiateResponse = request(caller, creditParseUrl, initiateParams, "发起接口");
|
||||
requireSuccessfulInitiateResponse(initiateResponse, "征信解析发起接口");
|
||||
|
||||
CreditParseInvokeResponse response = queryResult(serialNum);
|
||||
CreditParseInvokeResponse response = queryResult(caller, serialNum);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("【征信解析】调用完成: success={}, code={}, businessStatusCode={}, cost={}ms",
|
||||
@@ -94,10 +95,10 @@ public class CreditParseClient {
|
||||
return params;
|
||||
}
|
||||
|
||||
private CreditParseInvokeResponse queryResult(String serialNum) {
|
||||
private CreditParseInvokeResponse queryResult(CallerContext caller, String serialNum) {
|
||||
Map<String, Object> params = buildBaseParams(serialNum);
|
||||
for (int attempt = 1; attempt <= RESULT_QUERY_MAX_ATTEMPTS; attempt++) {
|
||||
CreditParseInvokeResponse response = request(creditParseResultUrl, params,
|
||||
CreditParseInvokeResponse response = request(caller, creditParseResultUrl, params,
|
||||
"结果接口第" + attempt + "次查询");
|
||||
requireSuccessfulServiceResponse(response, "征信解析结果接口");
|
||||
if (response.getData() == null || response.getData().getMappingOutputFields() == null) {
|
||||
@@ -130,10 +131,10 @@ public class CreditParseClient {
|
||||
Thread.sleep(intervalMillis);
|
||||
}
|
||||
|
||||
private CreditParseInvokeResponse request(String url, Map<String, Object> params, String stage) {
|
||||
private CreditParseInvokeResponse request(CallerContext caller, String url, Map<String, Object> params, String stage) {
|
||||
try {
|
||||
log.info("【征信解析】{}请求: url={}, params={}", stage, url, toJson(params));
|
||||
String responseJson = httpUtil.postUrlEncodedFormForString(url, params, null);
|
||||
String responseJson = httpUtil.postUrlEncodedFormForString(caller, url, params, null);
|
||||
log.info("【征信解析】{}返回JSON: {}", stage, responseJson);
|
||||
return objectMapper.readValue(responseJson, CreditParseInvokeResponse.class);
|
||||
} catch (LsfxApiException e) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.lsfx.client;
|
||||
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.request.DeleteFilesRequest;
|
||||
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
|
||||
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
|
||||
@@ -68,7 +69,7 @@ public class LsfxAnalysisClient {
|
||||
/**
|
||||
* 获取Token
|
||||
*/
|
||||
public GetTokenResponse getToken(GetTokenRequest request) {
|
||||
public GetTokenResponse getToken(CallerContext caller, GetTokenRequest request) {
|
||||
log.info("【流水分析】获取Token请求: projectNo={}, entityName={}", request.getProjectNo(), request.getEntityName());
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
@@ -88,7 +89,7 @@ public class LsfxAnalysisClient {
|
||||
params.put("analysisType", request.getAnalysisType() != null ? request.getAnalysisType() : LsfxConstants.ANALYSIS_TYPE);
|
||||
|
||||
String url = baseUrl + getTokenEndpoint;
|
||||
GetTokenResponse response = httpUtil.postFormData(url, params, null, GetTokenResponse.class);
|
||||
GetTokenResponse response = httpUtil.postFormData(caller, url, params, null, GetTokenResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
if (response != null && response.getData() != null) {
|
||||
@@ -110,14 +111,14 @@ public class LsfxAnalysisClient {
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
public UploadFileResponse uploadFile(Integer groupId, File file) {
|
||||
return uploadFile(groupId, file, file.getName());
|
||||
public UploadFileResponse uploadFile(CallerContext caller, Integer groupId, File file) {
|
||||
return uploadFile(caller, groupId, file, file.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
public UploadFileResponse uploadFile(Integer groupId, File file, String uploadFileName) {
|
||||
public UploadFileResponse uploadFile(CallerContext caller, Integer groupId, File file, String uploadFileName) {
|
||||
String multipartFileName = StringUtils.hasText(uploadFileName) ? uploadFileName : file.getName();
|
||||
log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, multipartFileName);
|
||||
long startTime = System.currentTimeMillis();
|
||||
@@ -132,7 +133,7 @@ public class LsfxAnalysisClient {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
UploadFileResponse response = httpUtil.uploadFile(url, params, headers, UploadFileResponse.class);
|
||||
UploadFileResponse response = httpUtil.uploadFile(caller, url, params, headers, UploadFileResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
if (response != null && response.getData() != null) {
|
||||
@@ -155,7 +156,7 @@ public class LsfxAnalysisClient {
|
||||
/**
|
||||
* 拉取行内流水
|
||||
*/
|
||||
public FetchInnerFlowResponse fetchInnerFlow(FetchInnerFlowRequest request) {
|
||||
public FetchInnerFlowResponse fetchInnerFlow(CallerContext caller, FetchInnerFlowRequest request) {
|
||||
log.info("【流水分析】拉取行内流水请求: groupId={}, customerNo={}", request.getGroupId(), request.getCustomerNo());
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
@@ -168,7 +169,7 @@ public class LsfxAnalysisClient {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
FetchInnerFlowResponse response = httpUtil.postFormData(url, params, headers, FetchInnerFlowResponse.class);
|
||||
FetchInnerFlowResponse response = httpUtil.postFormData(caller, url, params, headers, FetchInnerFlowResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
if (response != null && response.getData() != null) {
|
||||
@@ -190,7 +191,7 @@ public class LsfxAnalysisClient {
|
||||
/**
|
||||
* 检查文件解析状态
|
||||
*/
|
||||
public CheckParseStatusResponse checkParseStatus(Integer groupId, String inprogressList) {
|
||||
public CheckParseStatusResponse checkParseStatus(CallerContext caller, Integer groupId, String inprogressList) {
|
||||
log.info("【流水分析】检查文件解析状态: groupId={}, inprogressList={}", groupId, inprogressList);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
@@ -205,7 +206,7 @@ public class LsfxAnalysisClient {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
CheckParseStatusResponse response = httpUtil.postFormData(url, params, headers, CheckParseStatusResponse.class);
|
||||
CheckParseStatusResponse response = httpUtil.postFormData(caller, url, params, headers, CheckParseStatusResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
if (response != null && response.getData() != null) {
|
||||
@@ -234,7 +235,7 @@ public class LsfxAnalysisClient {
|
||||
* @param request 请求参数(groupId, logId, pageNow, pageSize)
|
||||
* @return 流水明细列表
|
||||
*/
|
||||
public GetBankStatementResponse getBankStatement(GetBankStatementRequest request) {
|
||||
public GetBankStatementResponse getBankStatement(CallerContext caller, GetBankStatementRequest request) {
|
||||
log.info("【流水分析】获取银行流水请求: groupId={}, logId={}, pageNow={}, pageSize={}",
|
||||
request.getGroupId(), request.getLogId(), request.getPageNow(), request.getPageSize());
|
||||
long startTime = System.currentTimeMillis();
|
||||
@@ -248,7 +249,7 @@ public class LsfxAnalysisClient {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
GetBankStatementResponse response = httpUtil.postFormData(url, params, headers, GetBankStatementResponse.class);
|
||||
GetBankStatementResponse response = httpUtil.postFormData(caller, url, params, headers, GetBankStatementResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
if (response != null && response.getData() != null) {
|
||||
@@ -281,7 +282,7 @@ public class LsfxAnalysisClient {
|
||||
* @param request 请求参数(groupId必填, logId可选)
|
||||
* @return 文件上传状态信息
|
||||
*/
|
||||
public GetFileUploadStatusResponse getFileUploadStatus(GetFileUploadStatusRequest request) {
|
||||
public GetFileUploadStatusResponse getFileUploadStatus(CallerContext caller, GetFileUploadStatusRequest request) {
|
||||
log.info("【流水分析】获取文件上传状态: groupId={}, logId={}",
|
||||
request.getGroupId(), request.getLogId());
|
||||
long startTime = System.currentTimeMillis();
|
||||
@@ -299,7 +300,7 @@ public class LsfxAnalysisClient {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
GetFileUploadStatusResponse response = httpUtil.get(url, params, headers,
|
||||
GetFileUploadStatusResponse response = httpUtil.get(caller, url, params, headers,
|
||||
GetFileUploadStatusResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
@@ -334,7 +335,7 @@ public class LsfxAnalysisClient {
|
||||
* @param request 请求参数(groupId, logIds, userId必填)
|
||||
* @return 删除结果
|
||||
*/
|
||||
public DeleteFilesResponse deleteFiles(DeleteFilesRequest request) {
|
||||
public DeleteFilesResponse deleteFiles(CallerContext caller, DeleteFilesRequest request) {
|
||||
log.info("【流水分析】删除文件请求: groupId={}, logIds={}, userId={}",
|
||||
request.getGroupId(), Arrays.toString(request.getLogIds()), request.getUserId());
|
||||
long startTime = System.currentTimeMillis();
|
||||
@@ -351,7 +352,7 @@ public class LsfxAnalysisClient {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
DeleteFilesResponse response = httpUtil.postFormData(url, params, headers,
|
||||
DeleteFilesResponse response = httpUtil.postFormData(caller, url, params, headers,
|
||||
DeleteFilesResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
package com.ruoyi.lsfx.controller;
|
||||
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.lsfx.client.CreditParseClient;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
|
||||
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.security.access.prepost.PreAuthorize;
|
||||
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;
|
||||
|
||||
@Tag(name = "征信解析接口测试", description = "用于测试征信解析接口")
|
||||
@Anonymous
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
|
||||
@RestController
|
||||
@RequestMapping("/lsfx/credit")
|
||||
public class CreditParseController {
|
||||
@@ -37,7 +39,8 @@ public class CreditParseController {
|
||||
String actualModel = StringUtils.isBlank(model) ? DEFAULT_MODEL : model;
|
||||
|
||||
try {
|
||||
CreditParseInvokeResponse response = creditParseClient.parse(actualModel, remotePath);
|
||||
CreditParseInvokeResponse response = creditParseClient.parse(
|
||||
CallerContext.from(SecurityUtils.getLoginUser()), actualModel, remotePath);
|
||||
return AjaxResult.success(response);
|
||||
} catch (LsfxApiException e) {
|
||||
return AjaxResult.error(e.getMessage());
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.ruoyi.lsfx.controller;
|
||||
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.request.DeleteFilesRequest;
|
||||
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
|
||||
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
|
||||
@@ -15,6 +16,7 @@ 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.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -28,7 +30,7 @@ import java.nio.file.StandardCopyOption;
|
||||
* 流水分析平台接口测试控制器
|
||||
*/
|
||||
@Tag(name = "流水分析平台接口测试", description = "用于测试流水分析平台的7个接口")
|
||||
@Anonymous
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
|
||||
@RestController
|
||||
@RequestMapping("/lsfx/test")
|
||||
public class LsfxTestController {
|
||||
@@ -61,7 +63,7 @@ public class LsfxTestController {
|
||||
request.setDepartmentCode(LsfxConstants.DEFAULT_DEPARTMENT_CODE);
|
||||
}
|
||||
|
||||
GetTokenResponse response = lsfxAnalysisClient.getToken(request);
|
||||
GetTokenResponse response = lsfxAnalysisClient.getToken(currentCaller(), request);
|
||||
return AjaxResult.success(response);
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ public class LsfxTestController {
|
||||
Files.copy(file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
File convertedFile = tempFile.toFile();
|
||||
UploadFileResponse response = lsfxAnalysisClient.uploadFile(groupId, convertedFile);
|
||||
UploadFileResponse response = lsfxAnalysisClient.uploadFile(currentCaller(), groupId, convertedFile);
|
||||
return AjaxResult.success(response);
|
||||
} catch (IOException e) {
|
||||
return AjaxResult.error("文件转换失败:" + e.getMessage());
|
||||
@@ -134,7 +136,7 @@ public class LsfxTestController {
|
||||
request.setDataChannelCode(LsfxConstants.DEFAULT_DATA_CHANNEL_CODE);
|
||||
}
|
||||
|
||||
FetchInnerFlowResponse response = lsfxAnalysisClient.fetchInnerFlow(request);
|
||||
FetchInnerFlowResponse response = lsfxAnalysisClient.fetchInnerFlow(currentCaller(), request);
|
||||
return AjaxResult.success(response);
|
||||
}
|
||||
|
||||
@@ -152,7 +154,7 @@ public class LsfxTestController {
|
||||
return AjaxResult.error("参数不完整:inprogressList为必填");
|
||||
}
|
||||
|
||||
CheckParseStatusResponse response = lsfxAnalysisClient.checkParseStatus(groupId, inprogressList);
|
||||
CheckParseStatusResponse response = lsfxAnalysisClient.checkParseStatus(currentCaller(), groupId, inprogressList);
|
||||
return AjaxResult.success(response);
|
||||
}
|
||||
|
||||
@@ -174,7 +176,7 @@ public class LsfxTestController {
|
||||
return AjaxResult.error("参数不完整:pageSize为必填且大于0");
|
||||
}
|
||||
|
||||
GetBankStatementResponse response = lsfxAnalysisClient.getBankStatement(request);
|
||||
GetBankStatementResponse response = lsfxAnalysisClient.getBankStatement(currentCaller(), request);
|
||||
return AjaxResult.success(response);
|
||||
}
|
||||
|
||||
@@ -194,7 +196,7 @@ public class LsfxTestController {
|
||||
request.setGroupId(groupId);
|
||||
request.setLogId(logId);
|
||||
|
||||
GetFileUploadStatusResponse response = lsfxAnalysisClient.getFileUploadStatus(request);
|
||||
GetFileUploadStatusResponse response = lsfxAnalysisClient.getFileUploadStatus(currentCaller(), request);
|
||||
return AjaxResult.success(response);
|
||||
}
|
||||
|
||||
@@ -213,7 +215,11 @@ public class LsfxTestController {
|
||||
return AjaxResult.error("参数不完整:userId为必填");
|
||||
}
|
||||
|
||||
DeleteFilesResponse response = lsfxAnalysisClient.deleteFiles(request);
|
||||
DeleteFilesResponse response = lsfxAnalysisClient.deleteFiles(currentCaller(), request);
|
||||
return AjaxResult.success(response);
|
||||
}
|
||||
|
||||
private CallerContext currentCaller() {
|
||||
return CallerContext.from(SecurityUtils.getLoginUser());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ruoyi.lsfx.domain;
|
||||
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 业务外部接口调用发起人快照。
|
||||
*/
|
||||
public record CallerContext(Long userId, String username) {
|
||||
|
||||
public CallerContext {
|
||||
if (!StringUtils.hasText(username)) {
|
||||
throw new IllegalArgumentException("接口调用账号不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
public static CallerContext from(LoginUser loginUser) {
|
||||
if (loginUser == null) {
|
||||
throw new IllegalArgumentException("登录用户不能为空");
|
||||
}
|
||||
return new CallerContext(loginUser.getUserId(), loginUser.getUsername());
|
||||
}
|
||||
|
||||
public static CallerContext of(Long userId, String username) {
|
||||
return new CallerContext(userId, username);
|
||||
}
|
||||
|
||||
public static CallerContext system() {
|
||||
return new CallerContext(null, "system");
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,48 @@
|
||||
package com.ruoyi.lsfx.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import com.ruoyi.system.domain.SysApiLog;
|
||||
import com.ruoyi.system.service.ISysApiLogService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.lang.reflect.Array;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HTTP请求工具类
|
||||
* 业务外部HTTP请求工具,统一保存完整调用日志。
|
||||
*/
|
||||
@Component
|
||||
public class HttpUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HttpUtil.class);
|
||||
private static final String CALL_SUCCESS = "0";
|
||||
private static final String CALL_FAILED = "1";
|
||||
|
||||
@Resource
|
||||
private RestTemplate restTemplate;
|
||||
@@ -32,6 +50,9 @@ public class HttpUtil {
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Resource
|
||||
private ISysApiLogService apiLogService;
|
||||
|
||||
public static org.springframework.core.io.Resource namedFileResource(File file, String filename) {
|
||||
return new NamedFileSystemResource(file, filename);
|
||||
}
|
||||
@@ -50,299 +71,245 @@ public class HttpUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送GET请求(带查询参数和请求头)
|
||||
* @param url 请求URL
|
||||
* @param params 查询参数
|
||||
* @param headers 请求头
|
||||
* @param responseType 响应类型
|
||||
* @return 响应对象
|
||||
*/
|
||||
public <T> T get(String url, Map<String, Object> params, Map<String, String> headers, Class<T> responseType) {
|
||||
public <T> T get(CallerContext caller, String url, Map<String, Object> params,
|
||||
Map<String, String> headers, Class<T> responseType) {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
|
||||
if (params != null) {
|
||||
params.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
builder.queryParam(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
String fullUrl = builder.toUriString();
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
return execute(caller, fullUrl, HttpMethod.GET, httpHeaders, params,
|
||||
new HttpEntity<>(httpHeaders), responseType, "API返回数据为空", "GET请求异常");
|
||||
}
|
||||
|
||||
public <T> T get(CallerContext caller, String url, Map<String, String> headers, Class<T> responseType) {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
return execute(caller, url, HttpMethod.GET, httpHeaders, null,
|
||||
new HttpEntity<>(httpHeaders), responseType, "API返回数据为空", "网络请求失败");
|
||||
}
|
||||
|
||||
public <T> T postJson(CallerContext caller, String url, Object request,
|
||||
Map<String, String> headers, Class<T> responseType) {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
|
||||
return execute(caller, url, HttpMethod.POST, httpHeaders, request,
|
||||
new HttpEntity<>(request, httpHeaders), responseType, "API返回数据为空", "网络请求失败");
|
||||
}
|
||||
|
||||
public <T> T postFormData(CallerContext caller, String url, Map<String, Object> params,
|
||||
Map<String, String> headers, Class<T> responseType) {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach(body::add);
|
||||
}
|
||||
return execute(caller, url, HttpMethod.POST, httpHeaders, params,
|
||||
new HttpEntity<>(body, httpHeaders), responseType, "API返回数据为空", "网络请求失败");
|
||||
}
|
||||
|
||||
public <T> T postUrlEncodedForm(CallerContext caller, String url, Map<String, Object> params,
|
||||
Map<String, String> headers, Class<T> responseType) {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
MultiValueMap<String, String> body = toUrlEncodedBody(params);
|
||||
return execute(caller, url, HttpMethod.POST, httpHeaders, params,
|
||||
new HttpEntity<>(body, httpHeaders), responseType, "API返回数据为空", "网络请求失败");
|
||||
}
|
||||
|
||||
public String postUrlEncodedFormForString(CallerContext caller, String url,
|
||||
Map<String, Object> params, Map<String, String> headers) {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
MultiValueMap<String, String> body = toUrlEncodedBody(params);
|
||||
return execute(caller, url, HttpMethod.POST, httpHeaders, params,
|
||||
new HttpEntity<>(body, httpHeaders), String.class, "API返回数据为空", "网络请求失败");
|
||||
}
|
||||
|
||||
public <T> T uploadFile(CallerContext caller, String url, Map<String, Object> params,
|
||||
Map<String, String> headers, Class<T> responseType) {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach((key, value) -> {
|
||||
if (value instanceof File file) {
|
||||
body.add(key, new FileSystemResource(file));
|
||||
} else {
|
||||
body.add(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
return execute(caller, url, HttpMethod.POST, httpHeaders, params,
|
||||
new HttpEntity<>(body, httpHeaders), responseType, "文件上传返回数据为空", "文件上传请求失败");
|
||||
}
|
||||
|
||||
private <T> T execute(CallerContext caller, String url, HttpMethod method, HttpHeaders requestHeaders,
|
||||
Object requestParams, HttpEntity<?> requestEntity, Class<T> responseType,
|
||||
String emptyResponseMessage, String requestFailureMessage) {
|
||||
if (caller == null) {
|
||||
throw new IllegalArgumentException("接口调用用户不能为空");
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
SysApiLog apiLog = createBaseApiLog(caller, url, method, startTime);
|
||||
try {
|
||||
// 构建URL with查询参数
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
|
||||
if (params != null && !params.isEmpty()) {
|
||||
params.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
builder.queryParam(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String fullUrl = builder.toUriString();
|
||||
log.debug("【HTTP GET】请求URL: {}", fullUrl);
|
||||
|
||||
// 创建请求头
|
||||
HttpHeaders httpHeaders = new HttpHeaders();
|
||||
if (headers != null) {
|
||||
headers.forEach(httpHeaders::add);
|
||||
}
|
||||
|
||||
// 构建请求实体
|
||||
HttpEntity<String> entity = new HttpEntity<>(httpHeaders);
|
||||
|
||||
// 执行GET请求
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
fullUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
String.class
|
||||
);
|
||||
|
||||
log.debug("【HTTP GET】响应状态: {}", response.getStatusCode());
|
||||
log.debug("【HTTP GET】响应内容: {}", response.getBody());
|
||||
|
||||
// 解析响应
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
return objectMapper.readValue(response.getBody(), responseType);
|
||||
} else {
|
||||
throw new LsfxApiException("GET请求失败: " + response.getStatusCode());
|
||||
}
|
||||
captureRequest(apiLog, requestHeaders, requestParams);
|
||||
} catch (Exception e) {
|
||||
log.error("【HTTP GET】请求异常: url={}, error={}", url, e.getMessage(), e);
|
||||
throw new LsfxApiException("GET请求异常: " + e.getMessage(), e);
|
||||
log.error("接口日志请求快照构建失败: method={}, url={}", method, url, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送GET请求(带请求头)
|
||||
* @param url 请求URL
|
||||
* @param headers 请求头
|
||||
* @param responseType 响应类型
|
||||
* @return 响应对象
|
||||
*/
|
||||
public <T> T get(String url, Map<String, String> headers, Class<T> responseType) {
|
||||
try {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
HttpEntity<Void> requestEntity = new HttpEntity<>(httpHeaders);
|
||||
|
||||
ResponseEntity<T> response = restTemplate.exchange(
|
||||
url, HttpMethod.GET, requestEntity, responseType
|
||||
);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, method, requestEntity, String.class);
|
||||
captureResponse(apiLog, response);
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode());
|
||||
throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode().value());
|
||||
}
|
||||
if (!StringUtils.hasText(response.getBody())) {
|
||||
throw new LsfxApiException(emptyResponseMessage);
|
||||
}
|
||||
|
||||
T body = response.getBody();
|
||||
if (body == null) {
|
||||
throw new LsfxApiException("API返回数据为空");
|
||||
}
|
||||
|
||||
return body;
|
||||
T result = parseResponse(response.getBody(), responseType);
|
||||
apiLog.setCallStatus(CALL_SUCCESS);
|
||||
return result;
|
||||
} catch (RestClientResponseException e) {
|
||||
apiLog.setResponseStatus(e.getStatusCode().value());
|
||||
apiLog.setResponseHeaders(safeSerialize(e.getResponseHeaders()));
|
||||
apiLog.setResponseBody(e.getResponseBodyAsString());
|
||||
apiLog.setErrorMsg(formatException(e));
|
||||
throw new LsfxApiException(requestFailureMessage + ": " + e.getMessage(), e);
|
||||
} catch (LsfxApiException e) {
|
||||
apiLog.setErrorMsg(formatException(e));
|
||||
throw e;
|
||||
} catch (RestClientException e) {
|
||||
throw new LsfxApiException("网络请求失败: " + e.getMessage(), e);
|
||||
apiLog.setErrorMsg(formatException(e));
|
||||
throw new LsfxApiException(requestFailureMessage + ": " + e.getMessage(), e);
|
||||
} catch (Exception e) {
|
||||
apiLog.setErrorMsg(formatException(e));
|
||||
throw new LsfxApiException("响应解析失败: " + e.getMessage(), e);
|
||||
} finally {
|
||||
apiLog.setCostTime(System.currentTimeMillis() - startTime);
|
||||
persistApiLog(apiLog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送POST请求(JSON格式,带请求头)
|
||||
* @param url 请求URL
|
||||
* @param request 请求对象
|
||||
* @param headers 请求头
|
||||
* @param responseType 响应类型
|
||||
* @return 响应对象
|
||||
*/
|
||||
public <T> T postJson(String url, Object request, Map<String, String> headers, Class<T> responseType) {
|
||||
try {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
HttpEntity<Object> requestEntity = new HttpEntity<>(request, httpHeaders);
|
||||
|
||||
ResponseEntity<T> response = restTemplate.postForEntity(url, requestEntity, responseType);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode());
|
||||
}
|
||||
|
||||
T body = response.getBody();
|
||||
if (body == null) {
|
||||
throw new LsfxApiException("API返回数据为空");
|
||||
}
|
||||
|
||||
return body;
|
||||
} catch (RestClientException e) {
|
||||
throw new LsfxApiException("网络请求失败: " + e.getMessage(), e);
|
||||
}
|
||||
private SysApiLog createBaseApiLog(CallerContext caller, String url, HttpMethod method, long startTime) {
|
||||
SysApiLog apiLog = new SysApiLog();
|
||||
apiLog.setCallerUserId(caller.userId());
|
||||
apiLog.setCallerUsername(caller.username());
|
||||
apiLog.setApiUrl(url);
|
||||
apiLog.setHttpMethod(method.name());
|
||||
apiLog.setCallStatus(CALL_FAILED);
|
||||
apiLog.setCallTime(new Date(startTime));
|
||||
return apiLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送POST请求(multipart/form-data格式,带请求头)
|
||||
* 用于提交表单数据(非文件上传场景)
|
||||
* @param url 请求URL
|
||||
* @param params 表单参数
|
||||
* @param headers 请求头
|
||||
* @param responseType 响应类型
|
||||
* @return 响应对象
|
||||
*/
|
||||
public <T> T postFormData(String url, Map<String, Object> params, Map<String, String> headers, Class<T> responseType) {
|
||||
try {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach(body::add);
|
||||
}
|
||||
|
||||
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, httpHeaders);
|
||||
|
||||
ResponseEntity<T> response = restTemplate.postForEntity(url, requestEntity, responseType);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode());
|
||||
}
|
||||
|
||||
T responseBody = response.getBody();
|
||||
if (responseBody == null) {
|
||||
throw new LsfxApiException("API返回数据为空");
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
} catch (RestClientException e) {
|
||||
throw new LsfxApiException("网络请求失败: " + e.getMessage(), e);
|
||||
}
|
||||
private void captureRequest(SysApiLog apiLog, HttpHeaders headers, Object params) {
|
||||
apiLog.setContentType(headers.getContentType() == null ? null : headers.getContentType().toString());
|
||||
apiLog.setRequestHeaders(safeSerialize(headers));
|
||||
apiLog.setRequestParams(safeSerialize(normalizeValue(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送POST请求(application/x-www-form-urlencoded格式,带请求头)
|
||||
* @param url 请求URL
|
||||
* @param params 表单参数
|
||||
* @param headers 请求头
|
||||
* @param responseType 响应类型
|
||||
* @return 响应对象
|
||||
*/
|
||||
public <T> T postUrlEncodedForm(String url, Map<String, Object> params, Map<String, String> headers, Class<T> responseType) {
|
||||
try {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
body.add(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, httpHeaders);
|
||||
|
||||
ResponseEntity<T> response = restTemplate.postForEntity(url, requestEntity, responseType);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode());
|
||||
}
|
||||
|
||||
T responseBody = response.getBody();
|
||||
if (responseBody == null) {
|
||||
throw new LsfxApiException("API返回数据为空");
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
} catch (RestClientException e) {
|
||||
throw new LsfxApiException("网络请求失败: " + e.getMessage(), e);
|
||||
}
|
||||
private void captureResponse(SysApiLog apiLog, ResponseEntity<String> response) {
|
||||
apiLog.setResponseStatus(response.getStatusCode().value());
|
||||
apiLog.setResponseHeaders(safeSerialize(response.getHeaders()));
|
||||
apiLog.setResponseBody(response.getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送POST请求(application/x-www-form-urlencoded格式)并返回原始JSON字符串
|
||||
* @param url 请求URL
|
||||
* @param params 表单参数
|
||||
* @param headers 请求头
|
||||
* @return 原始响应内容
|
||||
*/
|
||||
public String postUrlEncodedFormForString(String url, Map<String, Object> params, Map<String, String> headers) {
|
||||
try {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
body.add(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, httpHeaders);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode());
|
||||
}
|
||||
|
||||
String responseBody = response.getBody();
|
||||
if (responseBody == null) {
|
||||
throw new LsfxApiException("API返回数据为空");
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
} catch (RestClientException e) {
|
||||
throw new LsfxApiException("网络请求失败: " + e.getMessage(), e);
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T parseResponse(String responseBody, Class<T> responseType) throws Exception {
|
||||
if (String.class.equals(responseType)) {
|
||||
return (T) responseBody;
|
||||
}
|
||||
return objectMapper.readValue(responseBody, responseType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(Multipart格式)
|
||||
* @param url 请求URL
|
||||
* @param params 参数(包含文件)
|
||||
* @param headers 请求头
|
||||
* @param responseType 响应类型
|
||||
* @return 响应对象
|
||||
*/
|
||||
public <T> T uploadFile(String url, Map<String, Object> params, Map<String, String> headers, Class<T> responseType) {
|
||||
try {
|
||||
HttpHeaders httpHeaders = createHeaders(headers);
|
||||
httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach((key, value) -> {
|
||||
// 如果是File对象,包装为FileSystemResource
|
||||
if (value instanceof File) {
|
||||
File file = (File) value;
|
||||
body.add(key, new FileSystemResource(file));
|
||||
} else if (value instanceof org.springframework.core.io.Resource) {
|
||||
body.add(key, value);
|
||||
} else {
|
||||
body.add(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, httpHeaders);
|
||||
|
||||
ResponseEntity<T> response = restTemplate.postForEntity(url, requestEntity, responseType);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new LsfxApiException("文件上传失败,HTTP状态码: " + response.getStatusCode());
|
||||
}
|
||||
|
||||
T responseBody = response.getBody();
|
||||
if (responseBody == null) {
|
||||
throw new LsfxApiException("文件上传返回数据为空");
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
} catch (RestClientException e) {
|
||||
throw new LsfxApiException("文件上传请求失败: " + e.getMessage(), e);
|
||||
private MultiValueMap<String, String> toUrlEncodedBody(Map<String, Object> params) {
|
||||
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||
if (params != null) {
|
||||
params.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
body.add(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建请求头
|
||||
* @param headers 请求头Map
|
||||
* @return HttpHeaders对象
|
||||
*/
|
||||
private HttpHeaders createHeaders(Map<String, String> headers) {
|
||||
HttpHeaders httpHeaders = new HttpHeaders();
|
||||
if (headers != null && !headers.isEmpty()) {
|
||||
if (headers != null) {
|
||||
headers.forEach(httpHeaders::set);
|
||||
}
|
||||
return httpHeaders;
|
||||
}
|
||||
|
||||
private Object normalizeValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof File file) {
|
||||
return fileMetadata(file.getName(), file.length());
|
||||
}
|
||||
if (value instanceof FileSystemResource resource) {
|
||||
return fileMetadata(resource.getFilename(), resource.getFile().length());
|
||||
}
|
||||
if (value instanceof org.springframework.core.io.Resource resource) {
|
||||
return fileMetadata(resource.getFilename(), null);
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
map.forEach((key, item) -> normalized.put(String.valueOf(key), normalizeValue(item)));
|
||||
return normalized;
|
||||
}
|
||||
if (value instanceof Iterable<?> iterable) {
|
||||
List<Object> normalized = new ArrayList<>();
|
||||
iterable.forEach(item -> normalized.add(normalizeValue(item)));
|
||||
return normalized;
|
||||
}
|
||||
if (value.getClass().isArray()) {
|
||||
List<Object> normalized = new ArrayList<>();
|
||||
for (int i = 0; i < Array.getLength(value); i++) {
|
||||
normalized.add(normalizeValue(Array.get(value, i)));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Map<String, Object> fileMetadata(String filename, Long size) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("filename", filename);
|
||||
metadata.put("size", size);
|
||||
metadata.put("contentType", MediaTypeFactory.getMediaType(filename == null ? "" : filename)
|
||||
.map(MediaType::toString).orElse(null));
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private String safeSerialize(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
log.error("接口日志序列化失败", e);
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private String formatException(Exception exception) {
|
||||
StringWriter writer = new StringWriter();
|
||||
exception.printStackTrace(new PrintWriter(writer));
|
||||
return writer.toString();
|
||||
}
|
||||
|
||||
private void persistApiLog(SysApiLog apiLog) {
|
||||
try {
|
||||
apiLogService.recordApiLog(apiLog);
|
||||
} catch (Exception e) {
|
||||
log.error("接口日志入库失败: method={}, url={}", apiLog.getHttpMethod(), apiLog.getApiUrl(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.lsfx.client;
|
||||
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.response.UploadFileResponse;
|
||||
import com.ruoyi.lsfx.util.HttpUtil;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -48,10 +49,11 @@ class LsfxAnalysisClientTest {
|
||||
|
||||
ArgumentCaptor<Map<String, Object>> paramsCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
ArgumentCaptor<Map<String, String>> headersCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
when(httpUtil.uploadFile(eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class)))
|
||||
CallerContext caller = CallerContext.of(7L, "tester");
|
||||
when(httpUtil.uploadFile(eq(caller), eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
client.uploadFile(200, tempFile.toFile(), "银行流水A.xlsx");
|
||||
client.uploadFile(caller, 200, tempFile.toFile(), "银行流水A.xlsx");
|
||||
|
||||
assertEquals(200, paramsCaptor.getValue().get("groupId"));
|
||||
Resource filePart = assertInstanceOf(Resource.class, paramsCaptor.getValue().get("files"));
|
||||
|
||||
@@ -2,10 +2,14 @@ package com.ruoyi.lsfx.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import com.ruoyi.lsfx.client.CreditParseClient;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import com.ruoyi.lsfx.util.HttpUtil;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -13,10 +17,14 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -24,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -34,12 +43,19 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CreditParseControllerTest {
|
||||
|
||||
private static final CallerContext CALLER = CallerContext.of(7L, "tester");
|
||||
|
||||
@Mock
|
||||
private CreditParseClient client;
|
||||
|
||||
@InjectMocks
|
||||
private CreditParseController controller;
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_shouldRejectBlankRemotePath() {
|
||||
AjaxResult result = controller.parse(null, null);
|
||||
@@ -48,12 +64,13 @@ class CreditParseControllerTest {
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultModelWhenMissing() {
|
||||
setLoginUser(CALLER.userId(), CALLER.username());
|
||||
CreditParseInvokeResponse response = new CreditParseInvokeResponse();
|
||||
response.setSuccess(true);
|
||||
response.setCode(10000);
|
||||
|
||||
String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html";
|
||||
when(client.parse(eq("LXCUSTALL"), eq(remotePath))).thenReturn(response);
|
||||
when(client.parse(eq(CALLER), eq("LXCUSTALL"), eq(remotePath))).thenReturn(response);
|
||||
|
||||
AjaxResult result = controller.parse(remotePath, null);
|
||||
|
||||
@@ -63,7 +80,8 @@ class CreditParseControllerTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnAjaxErrorWhenClientThrows() {
|
||||
when(client.parse(anyString(), anyString()))
|
||||
setLoginUser(CALLER.userId(), CALLER.username());
|
||||
when(client.parse(any(CallerContext.class), anyString(), anyString()))
|
||||
.thenThrow(new LsfxApiException("超时"));
|
||||
|
||||
AjaxResult result = controller.parse("http://127.0.0.1:62318/profile/credit-html/a.html", null);
|
||||
@@ -71,6 +89,16 @@ class CreditParseControllerTest {
|
||||
assertEquals(500, result.get("code"));
|
||||
}
|
||||
|
||||
private void setLoginUser(Long userId, String username) {
|
||||
SysUser user = new SysUser();
|
||||
user.setUserName(username);
|
||||
LoginUser loginUser = new LoginUser(user, Set.of("*:*:*"));
|
||||
loginUser.setUserId(userId);
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void creditParseClient_shouldInitiateAndQueryResultWithSameSerialNum() throws Exception {
|
||||
@@ -88,18 +116,20 @@ class CreditParseControllerTest {
|
||||
String payload = "{\"lx_header\":{\"query_cert_no\":\"330101199001010011\",\"query_cust_name\":\"张三\",\"report_time\":\"2026-03-24\"},\"lx_debt\":{\"uncle_bank_house_bal\":\"1\"},\"lx_publictype\":{\"civil_cnt\":1}}";
|
||||
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeature"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn(initiateSuccessResponse());
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn(resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE"));
|
||||
|
||||
String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html";
|
||||
CreditParseInvokeResponse actual = parseClient.parse(remotePath);
|
||||
CreditParseInvokeResponse actual = parseClient.parse(CALLER, remotePath);
|
||||
|
||||
assertEquals(true, actual.getSuccess());
|
||||
assertEquals(10000, actual.getCode());
|
||||
@@ -107,12 +137,14 @@ class CreditParseControllerTest {
|
||||
.getPayload().getLxHeader().get("query_cert_no"));
|
||||
ArgumentCaptor<Map<String, Object>> initiateParamsCaptor = ArgumentCaptor.forClass((Class) Map.class);
|
||||
verify(httpUtil).postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeature"),
|
||||
initiateParamsCaptor.capture(),
|
||||
isNull()
|
||||
);
|
||||
ArgumentCaptor<Map<String, Object>> resultParamsCaptor = ArgumentCaptor.forClass((Class) Map.class);
|
||||
verify(httpUtil).postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
|
||||
resultParamsCaptor.capture(),
|
||||
isNull()
|
||||
@@ -152,21 +184,24 @@ class CreditParseControllerTest {
|
||||
String resultResponse = resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE");
|
||||
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeature"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn(initiateSuccessResponse());
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn(emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, resultResponse);
|
||||
|
||||
CreditParseInvokeResponse actual = parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html");
|
||||
CreditParseInvokeResponse actual = parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html");
|
||||
|
||||
assertEquals("330101199001010011", actual.getData().getMappingOutputFields()
|
||||
.getPayload().getLxHeader().get("query_cert_no"));
|
||||
verify(httpUtil, times(5)).postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
@@ -188,18 +223,20 @@ class CreditParseControllerTest {
|
||||
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
|
||||
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeature"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn(initiateSuccessResponse());
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn("{\"success\":true,\"code\":99999,\"data\":{\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"0\"}}}");
|
||||
|
||||
LsfxApiException exception = assertThrows(LsfxApiException.class,
|
||||
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
|
||||
() -> parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html"));
|
||||
|
||||
assertTrue(exception.getMessage().contains("平台状态码异常"));
|
||||
}
|
||||
@@ -218,18 +255,20 @@ class CreditParseControllerTest {
|
||||
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
|
||||
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeature"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn(initiateSuccessResponse());
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"reasonMessage\":\"解析失败\",\"reasonCode\":500,\"status\":0,\"mappingOutputFields\":{\"message\":\"结果异常\"}}}");
|
||||
|
||||
LsfxApiException exception = assertThrows(LsfxApiException.class,
|
||||
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
|
||||
() -> parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html"));
|
||||
|
||||
assertTrue(exception.getMessage().contains("解析失败"));
|
||||
}
|
||||
@@ -248,16 +287,18 @@ class CreditParseControllerTest {
|
||||
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
|
||||
|
||||
when(httpUtil.postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeature"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
)).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"mappingOutputFields\":{\"message\":\"文件写入失败\"},\"reasonMessage\":\"文件写入失败\",\"reasonCode\":500,\"status\":0}}");
|
||||
|
||||
LsfxApiException exception = assertThrows(LsfxApiException.class,
|
||||
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
|
||||
() -> parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html"));
|
||||
|
||||
assertTrue(exception.getMessage().contains("文件写入失败"));
|
||||
verify(httpUtil, times(0)).postUrlEncodedFormForString(
|
||||
eq(CALLER),
|
||||
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
|
||||
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
|
||||
isNull()
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.ruoyi.lsfx.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.exception.LsfxApiException;
|
||||
import com.ruoyi.system.domain.SysApiLog;
|
||||
import com.ruoyi.system.service.ISysApiLogService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -8,52 +14,258 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HttpUtilTest {
|
||||
|
||||
private static final CallerContext CALLER = CallerContext.of(7L, "tester");
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@Mock
|
||||
private ISysApiLogService apiLogService;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void uploadFile_shouldUseExplicitResourceFilename() throws Exception {
|
||||
HttpUtil httpUtil = new HttpUtil();
|
||||
private HttpUtil httpUtil;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
httpUtil = new HttpUtil();
|
||||
ReflectionTestUtils.setField(httpUtil, "restTemplate", restTemplate);
|
||||
ReflectionTestUtils.setField(httpUtil, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(httpUtil, "apiLogService", apiLogService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_shouldUseExplicitFilenameAndOnlyPersistFileMetadata() throws Exception {
|
||||
Path tempFile = tempDir.resolve("batch_0_123456.xlsx");
|
||||
Files.writeString(tempFile, "content");
|
||||
Files.writeString(tempFile, "binary-content-must-not-enter-log");
|
||||
|
||||
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
|
||||
when(restTemplate.postForEntity(eq("http://lsfx/upload"), captor.capture(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("ok"));
|
||||
ArgumentCaptor<HttpEntity<?>> requestCaptor = httpEntityCaptor();
|
||||
when(restTemplate.exchange(eq("http://lsfx/upload"), eq(HttpMethod.POST),
|
||||
requestCaptor.capture(), eq(String.class))).thenReturn(ResponseEntity.ok("ok"));
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("groupId", 200);
|
||||
params.put("files", HttpUtil.namedFileResource(tempFile.toFile(), "银行流水A.xlsx"));
|
||||
|
||||
String result = httpUtil.uploadFile("http://lsfx/upload", params, null, String.class);
|
||||
assertEquals("ok", httpUtil.uploadFile(CALLER, "http://lsfx/upload", params, null, String.class));
|
||||
|
||||
assertEquals("ok", result);
|
||||
MultiValueMap<String, Object> body = (MultiValueMap<String, Object>) captor.getValue().getBody();
|
||||
Object filePart = body.getFirst("files");
|
||||
Resource resource = assertInstanceOf(Resource.class, filePart);
|
||||
MultiValueMap<String, Object> body = (MultiValueMap<String, Object>) requestCaptor.getValue().getBody();
|
||||
Resource resource = assertInstanceOf(Resource.class, body.getFirst("files"));
|
||||
assertEquals("银行流水A.xlsx", resource.getFilename());
|
||||
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
SysApiLog saved = logCaptor.getValue();
|
||||
assertEquals(7L, saved.getCallerUserId());
|
||||
assertEquals("tester", saved.getCallerUsername());
|
||||
assertTrue(saved.getRequestParams().contains("银行流水A.xlsx"));
|
||||
assertTrue(saved.getRequestParams().contains("\"size\":" + Files.size(tempFile)));
|
||||
assertFalse(saved.getRequestParams().contains("binary-content-must-not-enter-log"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void non2xx_shouldPersistOriginalStatusHeadersAndBody() {
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
responseHeaders.add("X-Trace-Id", "trace-1");
|
||||
HttpClientErrorException error = HttpClientErrorException.create(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Bad Request",
|
||||
responseHeaders,
|
||||
"{\"error\":\"参数错误\"}".getBytes(StandardCharsets.UTF_8),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
when(restTemplate.exchange(eq("http://lsfx/fail"), eq(HttpMethod.POST),
|
||||
any(HttpEntity.class), eq(String.class))).thenThrow(error);
|
||||
|
||||
assertThrows(LsfxApiException.class, () ->
|
||||
httpUtil.postJson(CALLER, "http://lsfx/fail", Map.of("token", "raw-token"), null, String.class));
|
||||
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
SysApiLog saved = logCaptor.getValue();
|
||||
assertEquals(400, saved.getResponseStatus());
|
||||
assertEquals("{\"error\":\"参数错误\"}", saved.getResponseBody());
|
||||
assertTrue(saved.getResponseHeaders().contains("trace-1"));
|
||||
assertTrue(saved.getRequestParams().contains("raw-token"));
|
||||
assertEquals("1", saved.getCallStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logPersistenceFailure_shouldNotChangeSuccessfulBusinessResult() {
|
||||
when(restTemplate.exchange(eq("http://lsfx/success"), eq(HttpMethod.GET),
|
||||
any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok"));
|
||||
doThrow(new RuntimeException("db unavailable")).when(apiLogService).recordApiLog(any(SysApiLog.class));
|
||||
|
||||
assertEquals("ok", httpUtil.get(CALLER, "http://lsfx/success", (Map<String, String>) null, String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void networkFailure_shouldPersistExceptionWithoutResponseStatus() {
|
||||
when(restTemplate.exchange(eq("http://lsfx/network-error"), eq(HttpMethod.GET),
|
||||
any(HttpEntity.class), eq(String.class))).thenThrow(new ResourceAccessException("connection refused"));
|
||||
|
||||
assertThrows(LsfxApiException.class, () ->
|
||||
httpUtil.get(CALLER, "http://lsfx/network-error", (Map<String, String>) null, String.class));
|
||||
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
SysApiLog saved = logCaptor.getValue();
|
||||
assertEquals(null, saved.getResponseStatus());
|
||||
assertTrue(saved.getErrorMsg().contains("connection refused"));
|
||||
assertEquals("1", saved.getCallStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyResponse_shouldPersistRawResponseBeforeFailing() {
|
||||
when(restTemplate.exchange(eq("http://lsfx/empty"), eq(HttpMethod.GET),
|
||||
any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok(" "));
|
||||
|
||||
assertThrows(LsfxApiException.class, () ->
|
||||
httpUtil.get(CALLER, "http://lsfx/empty", (Map<String, String>) null, String.class));
|
||||
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
SysApiLog saved = logCaptor.getValue();
|
||||
assertEquals(200, saved.getResponseStatus());
|
||||
assertEquals(" ", saved.getResponseBody());
|
||||
assertTrue(saved.getErrorMsg().contains("API返回数据为空"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseFailure_shouldPersistOriginalResponseBody() {
|
||||
String rawBody = "{\"value\":\"not-an-integer\",\"unknown\":true}";
|
||||
when(restTemplate.exchange(eq("http://lsfx/parse-error"), eq(HttpMethod.GET),
|
||||
any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok(rawBody));
|
||||
|
||||
assertThrows(LsfxApiException.class, () ->
|
||||
httpUtil.get(CALLER, "http://lsfx/parse-error", (Map<String, String>) null, Integer.class));
|
||||
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
SysApiLog saved = logCaptor.getValue();
|
||||
assertEquals(rawBody, saved.getResponseBody());
|
||||
assertEquals("1", saved.getCallStatus());
|
||||
assertTrue(saved.getErrorMsg() != null && !saved.getErrorMsg().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulResponse_shouldPersistCompleteRawBodyIncludingUnknownFields() {
|
||||
String rawBody = "{\"known\":1,\"unknown\":{\"nested\":true}}";
|
||||
when(restTemplate.exchange(eq("http://lsfx/raw"), eq(HttpMethod.GET),
|
||||
any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok(rawBody));
|
||||
|
||||
Map<?, ?> result = httpUtil.get(CALLER, "http://lsfx/raw", (Map<String, String>) null, Map.class);
|
||||
|
||||
assertEquals(Boolean.TRUE, ((Map<?, ?>) result.get("unknown")).get("nested"));
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
assertEquals(rawBody, logCaptor.getValue().getResponseBody());
|
||||
assertEquals("0", logCaptor.getValue().getCallStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestSnapshotFailure_shouldNotPreventExternalCallOrLogging() {
|
||||
Iterable<String> brokenSnapshotValue = () -> new Iterator<>() {
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
throw new IllegalStateException("snapshot failed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String next() {
|
||||
return "unused";
|
||||
}
|
||||
};
|
||||
when(restTemplate.exchange(eq("http://lsfx/snapshot-error"), eq(HttpMethod.POST),
|
||||
any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok"));
|
||||
|
||||
assertEquals("ok", httpUtil.postJson(CALLER, "http://lsfx/snapshot-error",
|
||||
brokenSnapshotValue, null, String.class));
|
||||
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
assertEquals("0", logCaptor.getValue().getCallStatus());
|
||||
verify(restTemplate).exchange(eq("http://lsfx/snapshot-error"), eq(HttpMethod.POST),
|
||||
any(HttpEntity.class), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allPublicRequestTypes_shouldUseUnifiedLoggingPath() {
|
||||
when(restTemplate.exchange(any(String.class), any(HttpMethod.class),
|
||||
any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok"));
|
||||
|
||||
Map<String, Object> params = Map.of("value", "完整参数");
|
||||
Map<String, String> headers = Map.of("client-id", "client-1");
|
||||
assertEquals("ok", httpUtil.get(CALLER, "http://lsfx/get", params, headers, String.class));
|
||||
assertEquals("ok", httpUtil.get(CALLER, "http://lsfx/get", headers, String.class));
|
||||
assertEquals("ok", httpUtil.postJson(CALLER, "http://lsfx/json", params, headers, String.class));
|
||||
assertEquals("ok", httpUtil.postFormData(CALLER, "http://lsfx/form-data", params, headers, String.class));
|
||||
assertEquals("ok", httpUtil.postUrlEncodedForm(CALLER, "http://lsfx/form", params, headers, String.class));
|
||||
assertEquals("ok", httpUtil.postUrlEncodedFormForString(CALLER, "http://lsfx/form-string", params, headers));
|
||||
assertEquals("ok", httpUtil.uploadFile(CALLER, "http://lsfx/upload", params, headers, String.class));
|
||||
|
||||
verify(apiLogService, times(7)).recordApiLog(any(SysApiLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingCaller_shouldFailBeforeHttpRequest() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
httpUtil.postJson(null, "http://lsfx/json", Map.of(), null, String.class));
|
||||
verify(restTemplate, never()).exchange(any(String.class), any(HttpMethod.class),
|
||||
any(HttpEntity.class), eq(String.class));
|
||||
verify(apiLogService, never()).recordApiLog(any(SysApiLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemCaller_shouldPersistNullUserIdAndSystemUsername() {
|
||||
when(restTemplate.exchange(eq("http://lsfx/system"), eq(HttpMethod.GET),
|
||||
any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok"));
|
||||
|
||||
assertEquals("ok", httpUtil.get(CallerContext.system(), "http://lsfx/system",
|
||||
(Map<String, String>) null, String.class));
|
||||
|
||||
ArgumentCaptor<SysApiLog> logCaptor = ArgumentCaptor.forClass(SysApiLog.class);
|
||||
verify(apiLogService).recordApiLog(logCaptor.capture());
|
||||
assertEquals(null, logCaptor.getValue().getCallerUserId());
|
||||
assertEquals("system", logCaptor.getValue().getCallerUsername());
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private ArgumentCaptor<HttpEntity<?>> httpEntityCaptor() {
|
||||
return (ArgumentCaptor) ArgumentCaptor.forClass(HttpEntity.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.controller;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
@@ -55,6 +56,19 @@ public class CcdiBankStatementController extends BaseController {
|
||||
return getDataTable(result.getRecords(), result.getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询对手方聚合列表
|
||||
*/
|
||||
@GetMapping("/counterparty/list")
|
||||
@Operation(summary = "分页查询对手方聚合列表")
|
||||
public TableDataInfo counterpartyList(CcdiBankStatementQueryDTO queryDTO) {
|
||||
projectAccessService.assertCanRead(queryDTO.getProjectId());
|
||||
PageDomain pageDomain = TableSupport.buildPageRequest();
|
||||
Page<CcdiBankStatementCounterpartyVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
|
||||
Page<CcdiBankStatementCounterpartyVO> result = bankStatementService.selectCounterpartyPage(page, queryDTO);
|
||||
return getDataTable(result.getRecords(), result.getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目级筛选项
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.core.page.TableSupport;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -77,8 +78,8 @@ public class CcdiFileUploadController extends BaseController {
|
||||
}
|
||||
|
||||
try {
|
||||
String username = SecurityUtils.getUsername();
|
||||
String batchId = fileUploadService.batchUploadFiles(projectId, files, username);
|
||||
CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser());
|
||||
String batchId = fileUploadService.batchUploadFiles(projectId, files, caller);
|
||||
return AjaxResult.success("上传任务已提交", batchId);
|
||||
} catch (RejectedExecutionException e) {
|
||||
log.warn("线程池已满,拒绝上传请求: projectId={}, fileCount={}", projectId, files.length);
|
||||
@@ -130,16 +131,14 @@ public class CcdiFileUploadController extends BaseController {
|
||||
return AjaxResult.error("开始日期和结束日期不能为空");
|
||||
}
|
||||
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
String username = SecurityUtils.getUsername();
|
||||
CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser());
|
||||
String batchId = fileUploadService.submitPullBankInfo(
|
||||
dto.getProjectId(),
|
||||
dto.getIdCards(),
|
||||
dataChannelCode,
|
||||
dto.getStartDate(),
|
||||
dto.getEndDate(),
|
||||
userId,
|
||||
username
|
||||
caller
|
||||
);
|
||||
return AjaxResult.success("拉取任务已提交", batchId);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -43,7 +44,7 @@ public class CcdiProjectController extends BaseController {
|
||||
@Operation(summary = "创建项目")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:add')")
|
||||
public AjaxResult createProject(@Validated @RequestBody CcdiProjectSaveDTO dto) {
|
||||
CcdiProjectVO vo = projectService.createProject(dto);
|
||||
CcdiProjectVO vo = projectService.createProject(dto, CallerContext.from(SecurityUtils.getLoginUser()));
|
||||
return AjaxResult.success("项目创建成功", vo);
|
||||
}
|
||||
|
||||
@@ -130,7 +131,7 @@ public class CcdiProjectController extends BaseController {
|
||||
@Operation(summary = "导入历史项目")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:add')")
|
||||
public AjaxResult importFromHistory(@Validated @RequestBody CcdiProjectImportHistoryDTO dto) {
|
||||
CcdiProjectVO vo = projectService.importFromHistory(dto, SecurityUtils.getUsername());
|
||||
CcdiProjectVO vo = projectService.importFromHistory(dto, CallerContext.from(SecurityUtils.getLoginUser()));
|
||||
return AjaxResult.success("项目创建成功", vo);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
|
||||
@@ -24,6 +26,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountVO;
|
||||
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
|
||||
import com.ruoyi.common.utils.poi.ExcelUtil;
|
||||
@@ -226,6 +230,30 @@ public class CcdiProjectOverviewController extends BaseController {
|
||||
return AjaxResult.success(pageVO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询未提交账户
|
||||
*/
|
||||
@GetMapping("/unsubmitted-accounts")
|
||||
@Operation(summary = "查询未提交账户")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getUnsubmittedAccounts(CcdiProjectUnsubmittedAccountQueryDTO queryDTO) {
|
||||
projectAccessService.assertCanRead(queryDTO.getProjectId());
|
||||
CcdiProjectUnsubmittedAccountVO result = overviewService.getUnsubmittedAccounts(queryDTO);
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询未提交账户流水
|
||||
*/
|
||||
@GetMapping("/unsubmitted-accounts/flows")
|
||||
@Operation(summary = "查询未提交账户流水")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getUnsubmittedAccountFlows(CcdiProjectUnsubmittedAccountQueryDTO queryDTO) {
|
||||
projectAccessService.assertCanRead(queryDTO.getProjectId());
|
||||
List<CcdiProjectUnsubmittedAccountFlowVO> rows = overviewService.getUnsubmittedAccountFlows(queryDTO);
|
||||
return AjaxResult.success(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出涉疑交易明细
|
||||
*/
|
||||
@@ -302,6 +330,23 @@ public class CcdiProjectOverviewController extends BaseController {
|
||||
util.exportExcel(response, rows, "外部人员风险模型命中人员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出未提交账户流水
|
||||
*/
|
||||
@PostMapping("/unsubmitted-accounts/export")
|
||||
@Operation(summary = "导出未提交账户流水")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public void exportUnsubmittedAccountFlows(
|
||||
HttpServletResponse response,
|
||||
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
|
||||
) {
|
||||
projectAccessService.assertCanRead(queryDTO.getProjectId());
|
||||
List<CcdiProjectUnsubmittedAccountFlowExcel> rows = overviewService.exportUnsubmittedAccountFlows(queryDTO);
|
||||
ExcelUtil<CcdiProjectUnsubmittedAccountFlowExcel> util =
|
||||
new ExcelUtil<>(CcdiProjectUnsubmittedAccountFlowExcel.class);
|
||||
util.exportExcel(response, rows, "未提交账户流水");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出风险明细
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 未提交账户查询参数
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectUnsubmittedAccountQueryDTO {
|
||||
|
||||
/**
|
||||
* 项目ID
|
||||
*/
|
||||
private Long projectId;
|
||||
|
||||
/**
|
||||
* 主体名称
|
||||
*/
|
||||
private String subjectName;
|
||||
|
||||
/**
|
||||
* 未提交账号
|
||||
*/
|
||||
private String accountNo;
|
||||
}
|
||||
@@ -35,9 +35,9 @@ public class CcdiProjectSuspiciousTransactionExcel {
|
||||
@Excel(name = "交易类型")
|
||||
private String cashType;
|
||||
|
||||
@Excel(name = "异常标签")
|
||||
private String hitTags;
|
||||
|
||||
@Excel(name = "交易金额")
|
||||
private BigDecimal displayAmount;
|
||||
|
||||
@Excel(name = "异常标签")
|
||||
private String hitTags;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ruoyi.ccdi.project.domain.excel;
|
||||
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 未提交账户流水导出对象
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectUnsubmittedAccountFlowExcel {
|
||||
|
||||
@Excel(name = "主体名称")
|
||||
private String subjectName;
|
||||
|
||||
@Excel(name = "未提交账号")
|
||||
private String accountNo;
|
||||
|
||||
@Excel(name = "交易时间")
|
||||
private String trxDate;
|
||||
|
||||
@Excel(name = "本方账号")
|
||||
private String leAccountNo;
|
||||
|
||||
@Excel(name = "本方主体")
|
||||
private String leAccountName;
|
||||
|
||||
@Excel(name = "对方名称")
|
||||
private String customerAccountName;
|
||||
|
||||
@Excel(name = "对方账号")
|
||||
private String customerAccountNo;
|
||||
|
||||
@Excel(name = "摘要")
|
||||
private String userMemo;
|
||||
|
||||
@Excel(name = "交易类型")
|
||||
private String cashType;
|
||||
|
||||
@Excel(name = "流入金额")
|
||||
private BigDecimal amountCr;
|
||||
|
||||
@Excel(name = "流出金额")
|
||||
private BigDecimal amountDr;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 流水对手方聚合VO
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Data
|
||||
public class CcdiBankStatementCounterpartyVO {
|
||||
|
||||
/** 对方名称 */
|
||||
private String customerAccountName;
|
||||
|
||||
/** 对方名称是否为空 */
|
||||
private Boolean nameEmpty;
|
||||
|
||||
/** 关联账号数 */
|
||||
private Integer accountCount;
|
||||
|
||||
/** 流入合计 */
|
||||
private BigDecimal inAmount;
|
||||
|
||||
/** 流入笔数 */
|
||||
private Long inCount;
|
||||
|
||||
/** 流出合计 */
|
||||
private BigDecimal outAmount;
|
||||
|
||||
/** 流出笔数 */
|
||||
private Long outCount;
|
||||
|
||||
/** 总交易金额 */
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
/** 总笔数 */
|
||||
private Long totalCount;
|
||||
|
||||
/** 最近交易时间 */
|
||||
private String latestTrxDate;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
@@ -37,4 +38,6 @@ public class CcdiProjectOverviewReportVO {
|
||||
private List<CcdiProjectEmployeeCreditNegativeExcel> illegalPeople = new ArrayList<>();
|
||||
|
||||
private List<CcdiProjectAbnormalAccountExcel> abnormalAccounts = new ArrayList<>();
|
||||
|
||||
private List<CcdiProjectUnsubmittedAccountFlowExcel> unsubmittedAccountFlows = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 未提交账户流水行
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectUnsubmittedAccountFlowVO {
|
||||
|
||||
private Long bankStatementId;
|
||||
|
||||
private String subjectName;
|
||||
|
||||
private String accountNo;
|
||||
|
||||
private String trxDate;
|
||||
|
||||
private String leAccountNo;
|
||||
|
||||
private String leAccountName;
|
||||
|
||||
private String customerAccountName;
|
||||
|
||||
private String customerAccountNo;
|
||||
|
||||
private String userMemo;
|
||||
|
||||
private String cashType;
|
||||
|
||||
private BigDecimal amountCr;
|
||||
|
||||
private BigDecimal amountDr;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 未提交账户账号项
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectUnsubmittedAccountItemVO {
|
||||
|
||||
private String subjectName;
|
||||
|
||||
private String accountNo;
|
||||
|
||||
private BigDecimal totalAmountCr;
|
||||
|
||||
private BigDecimal totalAmountDr;
|
||||
|
||||
private BigDecimal netAmount;
|
||||
|
||||
private BigDecimal inflowRatio;
|
||||
|
||||
private BigDecimal outflowRatio;
|
||||
|
||||
private Boolean highRatio;
|
||||
|
||||
private Integer transactionCount;
|
||||
|
||||
private String startDate;
|
||||
|
||||
private String endDate;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 未提交账户主体分组
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectUnsubmittedAccountSubjectVO {
|
||||
|
||||
private String subjectName;
|
||||
|
||||
private Integer accountCount = 0;
|
||||
|
||||
private BigDecimal totalAmountCr = BigDecimal.ZERO;
|
||||
|
||||
private BigDecimal totalAmountDr = BigDecimal.ZERO;
|
||||
|
||||
private BigDecimal netAmount = BigDecimal.ZERO;
|
||||
|
||||
private List<CcdiProjectUnsubmittedAccountItemVO> accounts = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 未提交账户查询结果
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectUnsubmittedAccountVO {
|
||||
|
||||
private Integer accountCount = 0;
|
||||
|
||||
private BigDecimal totalAmountCr = BigDecimal.ZERO;
|
||||
|
||||
private BigDecimal totalAmountDr = BigDecimal.ZERO;
|
||||
|
||||
private BigDecimal netAmount = BigDecimal.ZERO;
|
||||
|
||||
private List<CcdiProjectUnsubmittedAccountSubjectVO> subjects = new ArrayList<>();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
@@ -38,6 +39,9 @@ public interface CcdiBankStatementMapper extends BaseMapper<CcdiBankStatement> {
|
||||
Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
|
||||
@Param("query") CcdiBankStatementQueryDTO query);
|
||||
|
||||
Page<CcdiBankStatementCounterpartyVO> selectCounterpartyPage(Page<CcdiBankStatementCounterpartyVO> page,
|
||||
@Param("query") CcdiBankStatementQueryDTO query);
|
||||
|
||||
List<CcdiBankStatementListVO> selectStatementListForExport(@Param("query") CcdiBankStatementQueryDTO query);
|
||||
|
||||
CcdiBankStatementDetailVO selectStatementDetailById(@Param("bankStatementId") Long bankStatementId);
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
|
||||
@@ -24,6 +25,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountItemVO;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@@ -259,6 +262,26 @@ public interface CcdiProjectOverviewMapper {
|
||||
*/
|
||||
List<CcdiProjectAbnormalAccountItemVO> selectAbnormalAccountList(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 查询未提交账户列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 未提交账户列表
|
||||
*/
|
||||
List<CcdiProjectUnsubmittedAccountItemVO> selectUnsubmittedAccountList(
|
||||
@Param("query") CcdiProjectUnsubmittedAccountQueryDTO query
|
||||
);
|
||||
|
||||
/**
|
||||
* 查询未提交账户流水
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 未提交账户流水
|
||||
*/
|
||||
List<CcdiProjectUnsubmittedAccountFlowVO> selectUnsubmittedAccountFlowList(
|
||||
@Param("query") CcdiProjectUnsubmittedAccountQueryDTO query
|
||||
);
|
||||
|
||||
/**
|
||||
* 查询项目员工负面征信导出列表
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
@@ -34,6 +35,16 @@ public interface ICcdiBankStatementService {
|
||||
Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
|
||||
CcdiBankStatementQueryDTO queryDTO);
|
||||
|
||||
/**
|
||||
* 分页查询对手方聚合列表
|
||||
*
|
||||
* @param page 分页对象
|
||||
* @param queryDTO 查询条件
|
||||
* @return 分页结果
|
||||
*/
|
||||
Page<CcdiBankStatementCounterpartyVO> selectCounterpartyPage(Page<CcdiBankStatementCounterpartyVO> page,
|
||||
CcdiBankStatementQueryDTO queryDTO);
|
||||
|
||||
/**
|
||||
* 查询导出列表
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
@@ -24,7 +25,7 @@ public interface ICcdiFileUploadService {
|
||||
* @param username 上传人
|
||||
* @return 批次ID
|
||||
*/
|
||||
String batchUploadFiles(Long projectId, MultipartFile[] files, String username);
|
||||
String batchUploadFiles(Long projectId, MultipartFile[] files, CallerContext caller);
|
||||
|
||||
/**
|
||||
* 解析身份证文件
|
||||
@@ -51,8 +52,7 @@ public interface ICcdiFileUploadService {
|
||||
String dataChannelCode,
|
||||
String startDate,
|
||||
String endDate,
|
||||
Long userId,
|
||||
String username);
|
||||
CallerContext caller);
|
||||
|
||||
/**
|
||||
* 删除上传记录并清理关联数据
|
||||
|
||||
@@ -9,12 +9,14 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
|
||||
@@ -26,6 +28,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountVO;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.util.List;
|
||||
@@ -278,6 +282,52 @@ public interface ICcdiProjectOverviewService {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询未提交账户
|
||||
*
|
||||
* @param queryDTO 查询条件
|
||||
* @return 未提交账户结果
|
||||
*/
|
||||
default CcdiProjectUnsubmittedAccountVO getUnsubmittedAccounts(
|
||||
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
|
||||
) {
|
||||
return new CcdiProjectUnsubmittedAccountVO();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询未提交账户流水
|
||||
*
|
||||
* @param queryDTO 查询条件
|
||||
* @return 未提交账户流水
|
||||
*/
|
||||
default List<CcdiProjectUnsubmittedAccountFlowVO> getUnsubmittedAccountFlows(
|
||||
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
|
||||
) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出未提交账户流水
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 导出列表
|
||||
*/
|
||||
default List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(Long projectId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出未提交账户流水
|
||||
*
|
||||
* @param queryDTO 查询条件
|
||||
* @return 导出列表
|
||||
*/
|
||||
default List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(
|
||||
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
|
||||
) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算结果总览员工结果并同步项目风险人数
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -22,7 +23,7 @@ public interface ICcdiProjectService {
|
||||
* @param dto 项目保存DTO
|
||||
* @return 项目VO
|
||||
*/
|
||||
CcdiProjectVO createProject(CcdiProjectSaveDTO dto);
|
||||
CcdiProjectVO createProject(CcdiProjectSaveDTO dto, CallerContext caller);
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
@@ -81,7 +82,7 @@ public interface ICcdiProjectService {
|
||||
* @param operator 操作人
|
||||
* @return 新建项目
|
||||
*/
|
||||
CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, String operator);
|
||||
CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, CallerContext caller);
|
||||
|
||||
/**
|
||||
* 查询各状态的项目总数(不受搜索条件影响)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
|
||||
@@ -57,6 +58,16 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<CcdiBankStatementCounterpartyVO> selectCounterpartyPage(
|
||||
Page<CcdiBankStatementCounterpartyVO> page,
|
||||
CcdiBankStatementQueryDTO queryDTO
|
||||
) {
|
||||
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
|
||||
normalizeQuery(normalizedQuery);
|
||||
return bankStatementMapper.selectCounterpartyPage(page, normalizedQuery);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CcdiBankStatementExcel> selectStatementListForExport(CcdiBankStatementQueryDTO queryDTO) {
|
||||
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
|
||||
@@ -153,6 +164,7 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
|
||||
queryDTO.setCounterpartyAccount(normalizeText(queryDTO.getCounterpartyAccount()));
|
||||
queryDTO.setTransactionType(normalizeText(queryDTO.getTransactionType()));
|
||||
queryDTO.setOurSubjects(normalizeList(queryDTO.getOurSubjects()));
|
||||
queryDTO.setOurCertNos(normalizeList(queryDTO.getOurCertNos()));
|
||||
queryDTO.setOurBanks(normalizeList(queryDTO.getOurBanks()));
|
||||
queryDTO.setOurAccounts(normalizeList(queryDTO.getOurAccounts()));
|
||||
queryDTO.setCounterpartyNameEmpty(Boolean.TRUE.equals(queryDTO.getCounterpartyNameEmpty()));
|
||||
@@ -180,6 +192,21 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
|
||||
if ("trxDate".equalsIgnoreCase(normalized)) {
|
||||
return "trxDate";
|
||||
}
|
||||
if ("latestTrxDate".equalsIgnoreCase(normalized)) {
|
||||
return "latestTrxDate";
|
||||
}
|
||||
if ("inAmount".equalsIgnoreCase(normalized)) {
|
||||
return "inAmount";
|
||||
}
|
||||
if ("outAmount".equalsIgnoreCase(normalized)) {
|
||||
return "outAmount";
|
||||
}
|
||||
if ("totalAmount".equalsIgnoreCase(normalized)) {
|
||||
return "totalAmount";
|
||||
}
|
||||
if ("totalCount".equalsIgnoreCase(normalized)) {
|
||||
return "totalCount";
|
||||
}
|
||||
return "trxDate";
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
|
||||
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
|
||||
import com.ruoyi.lsfx.domain.request.GetFileUploadStatusRequest;
|
||||
@@ -157,8 +158,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
String dataChannelCode,
|
||||
String startDate,
|
||||
String endDate,
|
||||
Long userId,
|
||||
String username) {
|
||||
CallerContext caller) {
|
||||
if (projectId == null) {
|
||||
throw new IllegalArgumentException("项目ID不能为空");
|
||||
}
|
||||
@@ -209,7 +209,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
record.setFileStatus("uploading");
|
||||
record.setAccountNos(normalized);
|
||||
record.setUploadTime(now);
|
||||
record.setUploadUser(username);
|
||||
record.setUploadUser(caller.username());
|
||||
records.add(record);
|
||||
}
|
||||
if (records.isEmpty()) {
|
||||
@@ -223,7 +223,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
public void afterCommit() {
|
||||
CompletableFuture.runAsync(() -> submitPullBankInfoTasks(
|
||||
projectId, lsfxProjectId, records, normalizedIdCards,
|
||||
normalizedDataChannelCode, startDate, endDate, batchId
|
||||
normalizedDataChannelCode, startDate, endDate, batchId, caller
|
||||
));
|
||||
}
|
||||
});
|
||||
@@ -345,9 +345,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public String batchUploadFiles(Long projectId, MultipartFile[] files, String username) {
|
||||
public String batchUploadFiles(Long projectId, MultipartFile[] files, CallerContext caller) {
|
||||
log.info("【文件上传】开始批量上传: projectId={}, 文件数量={}, username={}",
|
||||
projectId, files.length, username);
|
||||
projectId, files.length, caller.username());
|
||||
|
||||
projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许上传或拉取数据");
|
||||
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
|
||||
@@ -406,7 +406,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
record.setFileSize(file.getSize());
|
||||
record.setFileStatus("uploading");
|
||||
record.setUploadTime(now);
|
||||
record.setUploadUser(username);
|
||||
record.setUploadUser(caller.username());
|
||||
records.add(record);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
@@ -438,7 +438,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
public void afterCommit() {
|
||||
log.info("【文件上传】事务已提交,启动异步任务");
|
||||
CompletableFuture.runAsync(() -> {
|
||||
submitTasksAsync(projectId, finalLsfxProjectId, tempFilePaths, records, batchId);
|
||||
submitTasksAsync(projectId, finalLsfxProjectId, tempFilePaths, records, batchId, caller);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -496,7 +496,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
private void submitTasksAsync(Long projectId, Integer lsfxProjectId,
|
||||
List<String> tempFilePaths,
|
||||
List<CcdiFileUploadRecord> records,
|
||||
String batchId) {
|
||||
String batchId,
|
||||
CallerContext caller) {
|
||||
log.info("【文件上传】调度线程启动: projectId={}, batchId={}", projectId, batchId);
|
||||
|
||||
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
|
||||
@@ -519,7 +520,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
try {
|
||||
// 尝试提交异步任务
|
||||
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(
|
||||
() -> processFileAsync(projectId, lsfxProjectId, tempFilePath, record.getId(), batchId, record),
|
||||
() -> processFileAsync(projectId, lsfxProjectId, tempFilePath, record.getId(), batchId, record, caller),
|
||||
fileUploadExecutor
|
||||
);
|
||||
futures.add(future);
|
||||
@@ -600,7 +601,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
String dataChannelCode,
|
||||
String startDate,
|
||||
String endDate,
|
||||
String batchId) {
|
||||
String batchId,
|
||||
CallerContext caller) {
|
||||
log.info("【拉取本行信息】调度线程启动: projectId={}, batchId={}", projectId, batchId);
|
||||
|
||||
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
|
||||
@@ -619,7 +621,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
while (!submitted && retryCount < 2) {
|
||||
try {
|
||||
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(
|
||||
() -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, dataChannelCode, startDate, endDate),
|
||||
() -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard,
|
||||
dataChannelCode, startDate, endDate, caller),
|
||||
fileUploadExecutor
|
||||
);
|
||||
futures.add(future);
|
||||
@@ -660,7 +663,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
String idCard,
|
||||
String dataChannelCode,
|
||||
String startDate,
|
||||
String endDate ) {
|
||||
String endDate,
|
||||
CallerContext caller) {
|
||||
try {
|
||||
String normalizedDataChannelCode = normalizePullBankInfoDataChannelCode(dataChannelCode);
|
||||
FetchInnerFlowRequest request = new FetchInnerFlowRequest();
|
||||
@@ -677,7 +681,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
}
|
||||
request.setUploadUserId(LsfxConstants.DEFAULT_USER_ID);
|
||||
|
||||
FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(request);
|
||||
FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(caller, request);
|
||||
if (response == null || response.getData() == null || response.getData().isEmpty()) {
|
||||
throw new RuntimeException("拉取本行信息失败: 未返回logId");
|
||||
}
|
||||
@@ -687,7 +691,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
throw new RuntimeException("拉取本行信息失败: 未返回logId");
|
||||
}
|
||||
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, caller);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("【拉取本行信息】处理失败: idCard={}, recordId={}", idCard, record.getId(), e);
|
||||
@@ -709,7 +713,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
*/
|
||||
@Async("fileUploadExecutor")
|
||||
public boolean processFileAsync(Long projectId, Integer lsfxProjectId, String tempFilePath,
|
||||
Long recordId, String batchId, CcdiFileUploadRecord record) {
|
||||
Long recordId, String batchId, CcdiFileUploadRecord record,
|
||||
CallerContext caller) {
|
||||
log.info("【文件上传】开始处理文件: fileName={}, recordId={}, tempPath={}",
|
||||
record.getFileName(), recordId, tempFilePath);
|
||||
|
||||
@@ -730,7 +735,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
throw new RuntimeException("临时文件不存在: " + tempFilePath);
|
||||
}
|
||||
|
||||
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName());
|
||||
UploadFileResponse uploadResponse = lsfxClient.uploadFile(caller, lsfxProjectId, file, record.getFileName());
|
||||
if (uploadResponse == null || uploadResponse.getData() == null
|
||||
|| uploadResponse.getData().getUploadLogList() == null
|
||||
|| uploadResponse.getData().getUploadLogList().isEmpty()) {
|
||||
@@ -744,7 +749,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
}
|
||||
|
||||
log.info("【文件上传】文件上传成功: logId={}", logId);
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true);
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true, caller);
|
||||
log.info("【文件上传】处理完成: fileName={}", record.getFileName());
|
||||
return true;
|
||||
|
||||
@@ -780,22 +785,24 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
private void processRecordAfterLogIdReady(Long projectId,
|
||||
Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record,
|
||||
Integer logId) {
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false);
|
||||
Integer logId,
|
||||
CallerContext caller) {
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false, caller);
|
||||
}
|
||||
|
||||
private void processRecordAfterLogIdReady(Long projectId,
|
||||
Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record,
|
||||
Integer logId,
|
||||
boolean preserveRecordFileName) {
|
||||
boolean preserveRecordFileName,
|
||||
CallerContext caller) {
|
||||
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
|
||||
record.setLogId(logId);
|
||||
record.setFileStatus("parsing");
|
||||
recordMapper.updateById(record);
|
||||
|
||||
log.info("【文件上传】步骤4: 开始轮询解析状态");
|
||||
boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString());
|
||||
boolean parsingComplete = waitForParsingComplete(caller, lsfxProjectId, logId.toString());
|
||||
if (!parsingComplete) {
|
||||
throw new RuntimeException("解析超时(超过10分钟),请检查文件格式是否正确");
|
||||
}
|
||||
@@ -805,7 +812,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
statusRequest.setGroupId(lsfxProjectId);
|
||||
statusRequest.setLogId(logId);
|
||||
|
||||
GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(statusRequest);
|
||||
GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(caller, statusRequest);
|
||||
if (statusResponse == null || statusResponse.getData() == null
|
||||
|| statusResponse.getData().getLogs() == null
|
||||
|| statusResponse.getData().getLogs().isEmpty()) {
|
||||
@@ -846,8 +853,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
|
||||
log.info("【文件上传】步骤7: 获取流水数据");
|
||||
String fallbackCretNo = extractIdCardFromFileName(record.getFileName());
|
||||
FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId,
|
||||
fallbackCretNo);
|
||||
FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(caller, projectId, lsfxProjectId,
|
||||
logId, fallbackCretNo);
|
||||
if (!fetchResult.isSuccess()) {
|
||||
updateFailedRecord(record, fetchResult.getErrorMessage());
|
||||
return;
|
||||
@@ -867,7 +874,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
* @param logId 文件ID
|
||||
* @return true=解析完成,false=超时未完成
|
||||
*/
|
||||
private boolean waitForParsingComplete(Integer groupId, String logId) {
|
||||
private boolean waitForParsingComplete(CallerContext caller, Integer groupId, String logId) {
|
||||
log.info("【文件上传】开始轮询解析状态: groupId={}, logId={}", groupId, logId);
|
||||
|
||||
int maxRetries = 300;
|
||||
@@ -876,7 +883,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
for (int i = 1; i <= maxRetries; i++) {
|
||||
try {
|
||||
// 调用检查解析状态接口
|
||||
CheckParseStatusResponse response = lsfxClient.checkParseStatus(groupId, logId);
|
||||
CheckParseStatusResponse response = lsfxClient.checkParseStatus(caller, groupId, logId);
|
||||
|
||||
if (response == null || response.getData() == null) {
|
||||
log.warn("【文件上传】轮询第{}次: 响应数据为空", i);
|
||||
@@ -919,9 +926,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
* @param groupId 流水分析平台项目ID
|
||||
* @param logId 文件ID
|
||||
*/
|
||||
private FetchBankStatementResult fetchAndSaveBankStatements(Long projectId, Integer groupId,
|
||||
Integer logId,
|
||||
String fallbackCretNo) {
|
||||
private FetchBankStatementResult fetchAndSaveBankStatements(CallerContext caller, Long projectId, Integer groupId,
|
||||
Integer logId, String fallbackCretNo) {
|
||||
log.info("【文件上传】开始获取流水数据: projectId={}, groupId={}, logId={}",
|
||||
projectId, groupId, logId);
|
||||
|
||||
@@ -934,7 +940,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
firstRequest.setPageNow(1);
|
||||
firstRequest.setPageSize(1);
|
||||
|
||||
GetBankStatementResponse firstResponse = lsfxClient.getBankStatement(firstRequest);
|
||||
GetBankStatementResponse firstResponse = lsfxClient.getBankStatement(caller, firstRequest);
|
||||
if (firstResponse == null || firstResponse.getData() == null) {
|
||||
result.setSuccess(false);
|
||||
result.setErrorMessage("获取流水数据失败: 响应数据为空");
|
||||
@@ -968,7 +974,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
request.setPageNow(pageNow);
|
||||
request.setPageSize(pageSize);
|
||||
|
||||
GetBankStatementResponse response = lsfxClient.getBankStatement(request);
|
||||
GetBankStatementResponse response = lsfxClient.getBankStatement(caller, request);
|
||||
if (response == null || response.getData() == null
|
||||
|| response.getData().getBankStatementList() == null) {
|
||||
result.setSuccess(false);
|
||||
|
||||
@@ -246,6 +246,25 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
new float[] { 0.12F, 0.1F, 0.14F, 0.14F, 0.11F, 0.08F, 0.22F, 0.09F },
|
||||
"暂无异常账户信息"
|
||||
);
|
||||
|
||||
writer.subsection("4. 未提交账户流水表(共" + report.getUnsubmittedAccountFlows().size() + "条)");
|
||||
writer.table(
|
||||
List.of("主体", "未提交账号", "交易时间", "本方账号", "对方名称", "摘要/交易类型", "流入", "流出"),
|
||||
report.getUnsubmittedAccountFlows().stream()
|
||||
.map(item -> List.of(
|
||||
safeText(item.getSubjectName()),
|
||||
maskAccount(item.getAccountNo()),
|
||||
safeText(item.getTrxDate()),
|
||||
maskAccount(item.getLeAccountNo()),
|
||||
safeText(item.getCustomerAccountName()),
|
||||
formatSummaryAndCashType(item.getUserMemo(), item.getCashType()),
|
||||
formatMoney(item.getAmountCr()),
|
||||
formatMoney(item.getAmountDr())
|
||||
))
|
||||
.collect(Collectors.toList()),
|
||||
new float[] { 0.14F, 0.13F, 0.12F, 0.13F, 0.14F, 0.17F, 0.08F, 0.09F },
|
||||
"暂无未提交账户流水"
|
||||
);
|
||||
}
|
||||
|
||||
private LoadedChineseFont loadChineseFont(PDDocument document) throws IOException {
|
||||
|
||||
@@ -12,12 +12,14 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectRiskExclusion;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
|
||||
@@ -53,6 +55,10 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountSubjectVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
|
||||
@@ -67,6 +73,7 @@ import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -474,6 +481,57 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiProjectUnsubmittedAccountVO getUnsubmittedAccounts(CcdiProjectUnsubmittedAccountQueryDTO queryDTO) {
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
|
||||
List<CcdiProjectUnsubmittedAccountItemVO> rows =
|
||||
defaultList(overviewMapper.selectUnsubmittedAccountList(queryDTO));
|
||||
CcdiProjectUnsubmittedAccountVO result = new CcdiProjectUnsubmittedAccountVO();
|
||||
result.setAccountCount(rows.size());
|
||||
result.setTotalAmountCr(sumUnsubmittedAmount(rows, true));
|
||||
result.setTotalAmountDr(sumUnsubmittedAmount(rows, false));
|
||||
result.setNetAmount(result.getTotalAmountCr().subtract(result.getTotalAmountDr()));
|
||||
|
||||
Map<String, List<CcdiProjectUnsubmittedAccountItemVO>> groupedRows = rows.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
item -> item.getSubjectName() == null ? "" : item.getSubjectName(),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
));
|
||||
result.setSubjects(groupedRows.entrySet().stream()
|
||||
.map(entry -> buildUnsubmittedAccountSubject(entry.getKey(), entry.getValue()))
|
||||
.toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CcdiProjectUnsubmittedAccountFlowVO> getUnsubmittedAccountFlows(
|
||||
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
|
||||
) {
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
|
||||
return defaultList(overviewMapper.selectUnsubmittedAccountFlowList(queryDTO));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(Long projectId) {
|
||||
CcdiProjectUnsubmittedAccountQueryDTO queryDTO = new CcdiProjectUnsubmittedAccountQueryDTO();
|
||||
queryDTO.setProjectId(projectId);
|
||||
return exportUnsubmittedAccountFlows(queryDTO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(
|
||||
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
|
||||
) {
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
|
||||
return defaultList(overviewMapper.selectUnsubmittedAccountFlowList(queryDTO)).stream()
|
||||
.map(this::buildUnsubmittedAccountFlowExcelRow)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
|
||||
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
|
||||
@@ -491,8 +549,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
Long projectId = queryDTO.getProjectId();
|
||||
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows = exportEmployeeCreditNegative(projectId);
|
||||
List<CcdiProjectAbnormalAccountExcel> abnormalRows = exportAbnormalAccountPeople(projectId);
|
||||
List<CcdiProjectUnsubmittedAccountFlowExcel> unsubmittedRows = exportUnsubmittedAccountFlows(projectId);
|
||||
try {
|
||||
workbookExporter.export(response, projectId, suspiciousRows, creditRows, abnormalRows);
|
||||
workbookExporter.export(response, projectId, suspiciousRows, creditRows, abnormalRows, unsubmittedRows);
|
||||
} catch (IOException e) {
|
||||
throw new ServiceException("导出风险明细失败");
|
||||
}
|
||||
@@ -525,6 +584,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
));
|
||||
report.setIllegalPeople(exportEmployeeCreditNegative(projectId));
|
||||
report.setAbnormalAccounts(exportAbnormalAccountPeople(projectId));
|
||||
report.setUnsubmittedAccountFlows(exportUnsubmittedAccountFlows(projectId));
|
||||
|
||||
try {
|
||||
reportPdfExporter.export(response, report);
|
||||
@@ -910,6 +970,45 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
return row;
|
||||
}
|
||||
|
||||
private CcdiProjectUnsubmittedAccountSubjectVO buildUnsubmittedAccountSubject(
|
||||
String subjectName,
|
||||
List<CcdiProjectUnsubmittedAccountItemVO> rows
|
||||
) {
|
||||
CcdiProjectUnsubmittedAccountSubjectVO subject = new CcdiProjectUnsubmittedAccountSubjectVO();
|
||||
subject.setSubjectName(subjectName);
|
||||
subject.setAccountCount(rows.size());
|
||||
subject.setTotalAmountCr(sumUnsubmittedAmount(rows, true));
|
||||
subject.setTotalAmountDr(sumUnsubmittedAmount(rows, false));
|
||||
subject.setNetAmount(subject.getTotalAmountCr().subtract(subject.getTotalAmountDr()));
|
||||
subject.setAccounts(rows);
|
||||
return subject;
|
||||
}
|
||||
|
||||
private BigDecimal sumUnsubmittedAmount(List<CcdiProjectUnsubmittedAccountItemVO> rows, boolean inflow) {
|
||||
return rows.stream()
|
||||
.map(item -> inflow ? item.getTotalAmountCr() : item.getTotalAmountDr())
|
||||
.filter(value -> value != null)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
private CcdiProjectUnsubmittedAccountFlowExcel buildUnsubmittedAccountFlowExcelRow(
|
||||
CcdiProjectUnsubmittedAccountFlowVO item
|
||||
) {
|
||||
CcdiProjectUnsubmittedAccountFlowExcel row = new CcdiProjectUnsubmittedAccountFlowExcel();
|
||||
row.setSubjectName(item.getSubjectName());
|
||||
row.setAccountNo(item.getAccountNo());
|
||||
row.setTrxDate(item.getTrxDate());
|
||||
row.setLeAccountNo(item.getLeAccountNo());
|
||||
row.setLeAccountName(item.getLeAccountName());
|
||||
row.setCustomerAccountName(item.getCustomerAccountName());
|
||||
row.setCustomerAccountNo(item.getCustomerAccountNo());
|
||||
row.setUserMemo(item.getUserMemo());
|
||||
row.setCashType(item.getCashType());
|
||||
row.setAmountCr(item.getAmountCr());
|
||||
row.setAmountDr(item.getAmountDr());
|
||||
return row;
|
||||
}
|
||||
|
||||
private List<CcdiProjectOverviewReportParamVO> buildReportParams(CcdiProject project) {
|
||||
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : project.getProjectId();
|
||||
return defaultList(modelParamMapper.selectByProjectId(effectiveProjectId)).stream()
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service.impl;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
|
||||
import com.ruoyi.common.utils.file.FileUtils;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
@@ -29,7 +30,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
|
||||
Long projectId,
|
||||
List<CcdiProjectSuspiciousTransactionExcel> suspiciousRows,
|
||||
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows,
|
||||
List<CcdiProjectAbnormalAccountExcel> abnormalRows
|
||||
List<CcdiProjectAbnormalAccountExcel> abnormalRows,
|
||||
List<CcdiProjectUnsubmittedAccountFlowExcel> unsubmittedRows
|
||||
) throws IOException {
|
||||
response.setContentType(CONTENT_TYPE);
|
||||
FileUtils.setAttachmentResponseHeader(response, "风险明细_" + projectId + ".xlsx");
|
||||
@@ -38,6 +40,7 @@ public class CcdiProjectRiskDetailWorkbookExporter {
|
||||
writeSuspiciousSheet(workbook.createSheet("涉疑交易明细"), suspiciousRows);
|
||||
writeCreditSheet(workbook.createSheet("员工负面征信信息"), creditRows);
|
||||
writeAbnormalAccountSheet(workbook.createSheet("异常账户人员信息"), abnormalRows);
|
||||
writeUnsubmittedAccountSheet(workbook.createSheet("未提交账户流水"), unsubmittedRows);
|
||||
workbook.write(response.getOutputStream());
|
||||
}
|
||||
}
|
||||
@@ -53,8 +56,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
|
||||
"关联员工",
|
||||
"摘要",
|
||||
"交易类型",
|
||||
"异常标签",
|
||||
"交易金额"
|
||||
"交易金额",
|
||||
"异常标签"
|
||||
};
|
||||
writeHeader(header, headers);
|
||||
|
||||
@@ -69,8 +72,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
|
||||
row.createCell(5).setCellValue(safeText(item.getRelatedStaffDisplay()));
|
||||
row.createCell(6).setCellValue(safeText(item.getUserMemo()));
|
||||
row.createCell(7).setCellValue(safeText(item.getCashType()));
|
||||
row.createCell(8).setCellValue(safeText(item.getHitTags()));
|
||||
row.createCell(9).setCellValue(safeNumber(item.getDisplayAmount()));
|
||||
row.createCell(8).setCellValue(safeNumber(item.getDisplayAmount()));
|
||||
row.createCell(9).setCellValue(safeText(item.getHitTags()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +126,40 @@ public class CcdiProjectRiskDetailWorkbookExporter {
|
||||
}
|
||||
}
|
||||
|
||||
private void writeUnsubmittedAccountSheet(Sheet sheet, List<CcdiProjectUnsubmittedAccountFlowExcel> rows) {
|
||||
Row header = sheet.createRow(0);
|
||||
String[] headers = {
|
||||
"主体名称",
|
||||
"未提交账号",
|
||||
"交易时间",
|
||||
"本方账号",
|
||||
"本方主体",
|
||||
"对方名称",
|
||||
"对方账号",
|
||||
"摘要",
|
||||
"交易类型",
|
||||
"流入金额",
|
||||
"流出金额"
|
||||
};
|
||||
writeHeader(header, headers);
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
CcdiProjectUnsubmittedAccountFlowExcel item = rows.get(i);
|
||||
Row row = sheet.createRow(i + 1);
|
||||
row.createCell(0).setCellValue(safeText(item.getSubjectName()));
|
||||
row.createCell(1).setCellValue(safeText(item.getAccountNo()));
|
||||
row.createCell(2).setCellValue(safeText(item.getTrxDate()));
|
||||
row.createCell(3).setCellValue(safeText(item.getLeAccountNo()));
|
||||
row.createCell(4).setCellValue(safeText(item.getLeAccountName()));
|
||||
row.createCell(5).setCellValue(safeText(item.getCustomerAccountName()));
|
||||
row.createCell(6).setCellValue(safeText(item.getCustomerAccountNo()));
|
||||
row.createCell(7).setCellValue(safeText(item.getUserMemo()));
|
||||
row.createCell(8).setCellValue(safeText(item.getCashType()));
|
||||
row.createCell(9).setCellValue(safeNumber(item.getAmountCr()));
|
||||
row.createCell(10).setCellValue(safeNumber(item.getAmountDr()));
|
||||
}
|
||||
}
|
||||
|
||||
private void writeHeader(Row row, String[] headers) {
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
row.createCell(i).setCellValue(headers[i]);
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.request.GetTokenRequest;
|
||||
import com.ruoyi.lsfx.domain.response.GetTokenResponse;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -61,9 +62,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CcdiProjectVO createProject(CcdiProjectSaveDTO dto) {
|
||||
public CcdiProjectVO createProject(CcdiProjectSaveDTO dto, CallerContext caller) {
|
||||
// 1. 调用流水分析平台获取projectId
|
||||
Integer lsfxProjectId = callLsfxPlatform(dto.getProjectName());
|
||||
Integer lsfxProjectId = callLsfxPlatform(dto.getProjectName(), caller);
|
||||
|
||||
// 2. 创建项目实体
|
||||
CcdiProject project = new CcdiProject();
|
||||
@@ -163,18 +164,18 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, String operator) {
|
||||
public CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, CallerContext caller) {
|
||||
projectAccessService.assertSourceProjectsReadable(dto.getSourceProjectIds());
|
||||
CcdiProjectSaveDTO saveDTO = new CcdiProjectSaveDTO();
|
||||
saveDTO.setProjectName(dto.getProjectName());
|
||||
saveDTO.setDescription(dto.getDescription());
|
||||
saveDTO.setConfigType("default");
|
||||
CcdiProjectVO project = createProject(saveDTO);
|
||||
CcdiProjectVO project = createProject(saveDTO, caller);
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
applicationEventPublisher.publishEvent(
|
||||
new CcdiProjectHistoryImportSubmittedEvent(project.getProjectId(), project.getLsfxProjectId(), dto, operator)
|
||||
new CcdiProjectHistoryImportSubmittedEvent(project.getProjectId(), project.getLsfxProjectId(), dto, caller.username())
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -380,7 +381,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
|
||||
* @return 流水分析平台项目ID
|
||||
* @throws ServiceException 调用失败或响应无效时抛出
|
||||
*/
|
||||
private Integer callLsfxPlatform(String projectName) {
|
||||
private Integer callLsfxPlatform(String projectName, CallerContext caller) {
|
||||
// 构建请求参数
|
||||
GetTokenRequest request = new GetTokenRequest();
|
||||
request.setProjectNo("902000_" + System.currentTimeMillis());
|
||||
@@ -393,7 +394,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
|
||||
request.setDepartmentCode("902000");
|
||||
|
||||
// 调用流水分析平台(异常处理和日志已在 LsfxAnalysisClient 中完成)
|
||||
GetTokenResponse response = lsfxAnalysisClient.getToken(request);
|
||||
GetTokenResponse response = lsfxAnalysisClient.getToken(caller, request);
|
||||
|
||||
// 业务层校验:确保响应有效
|
||||
if (response == null || response.getData() == null) {
|
||||
|
||||
@@ -72,6 +72,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="displayAmount" column="displayAmount"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="CcdiBankStatementCounterpartyVOResultMap"
|
||||
type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO">
|
||||
<result property="customerAccountName" column="customerAccountName"/>
|
||||
<result property="nameEmpty" column="nameEmpty"/>
|
||||
<result property="accountCount" column="accountCount"/>
|
||||
<result property="inAmount" column="inAmount"/>
|
||||
<result property="inCount" column="inCount"/>
|
||||
<result property="outAmount" column="outAmount"/>
|
||||
<result property="outCount" column="outCount"/>
|
||||
<result property="totalAmount" column="totalAmount"/>
|
||||
<result property="totalCount" column="totalCount"/>
|
||||
<result property="latestTrxDate" column="latestTrxDate"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="CcdiBankStatementDetailVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO">
|
||||
<id property="bankStatementId" column="bankStatementId"/>
|
||||
<result property="projectId" column="projectId"/>
|
||||
@@ -284,6 +298,45 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</choose>
|
||||
</sql>
|
||||
|
||||
<sql id="counterpartyNameExpr">
|
||||
TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))
|
||||
</sql>
|
||||
|
||||
<sql id="counterpartyOrderBy">
|
||||
<choose>
|
||||
<when test="query.orderBy == 'inAmount' and query.orderDirection == 'asc'">
|
||||
ORDER BY inAmount ASC, totalCount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'inAmount' and query.orderDirection == 'desc'">
|
||||
ORDER BY inAmount DESC, totalCount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'outAmount' and query.orderDirection == 'asc'">
|
||||
ORDER BY outAmount ASC, totalCount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'outAmount' and query.orderDirection == 'desc'">
|
||||
ORDER BY outAmount DESC, totalCount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'totalCount' and query.orderDirection == 'asc'">
|
||||
ORDER BY totalCount ASC, totalAmount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'totalCount' and query.orderDirection == 'desc'">
|
||||
ORDER BY totalCount DESC, totalAmount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'latestTrxDate' and query.orderDirection == 'asc'">
|
||||
ORDER BY latestTrxDate ASC, totalAmount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'latestTrxDate' and query.orderDirection == 'desc'">
|
||||
ORDER BY latestTrxDate DESC, totalAmount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<when test="query.orderBy == 'totalAmount' and query.orderDirection == 'asc'">
|
||||
ORDER BY totalAmount ASC, totalCount DESC, customerAccountName ASC
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY totalAmount DESC, totalCount DESC, customerAccountName ASC
|
||||
</otherwise>
|
||||
</choose>
|
||||
</sql>
|
||||
|
||||
<select id="selectStatementPage" resultMap="CcdiBankStatementListVOResultMap">
|
||||
SELECT
|
||||
<include refid="statementListColumns"/>
|
||||
@@ -294,6 +347,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<include refid="statementOrderBy"/>
|
||||
</select>
|
||||
|
||||
<select id="selectCounterpartyPage" resultMap="CcdiBankStatementCounterpartyVOResultMap">
|
||||
SELECT
|
||||
grouped.customerAccountName,
|
||||
CASE WHEN grouped.customerAccountName = '' THEN 1 ELSE 0 END AS nameEmpty,
|
||||
grouped.accountCount,
|
||||
grouped.inAmount,
|
||||
grouped.inCount,
|
||||
grouped.outAmount,
|
||||
grouped.outCount,
|
||||
grouped.totalAmount,
|
||||
grouped.totalCount,
|
||||
grouped.latestTrxDate
|
||||
FROM (
|
||||
SELECT
|
||||
<include refid="counterpartyNameExpr"/> AS customerAccountName,
|
||||
COUNT(DISTINCT NULLIF(TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NO, '')), '')) AS accountCount,
|
||||
SUM(IFNULL(bs.AMOUNT_CR, 0)) AS inAmount,
|
||||
SUM(CASE WHEN IFNULL(bs.AMOUNT_CR, 0) > 0 THEN 1 ELSE 0 END) AS inCount,
|
||||
SUM(IFNULL(bs.AMOUNT_DR, 0)) AS outAmount,
|
||||
SUM(CASE WHEN IFNULL(bs.AMOUNT_DR, 0) > 0 THEN 1 ELSE 0 END) AS outCount,
|
||||
SUM(IFNULL(bs.AMOUNT_CR, 0) + IFNULL(bs.AMOUNT_DR, 0)) AS totalAmount,
|
||||
COUNT(1) AS totalCount,
|
||||
DATE_FORMAT(MAX(<include refid="parsedTrxDateExpr"/>), '%Y-%m-%d %H:%i:%s') AS latestTrxDate
|
||||
FROM ccdi_bank_statement bs
|
||||
<where>
|
||||
<include refid="statementFilterWhere"/>
|
||||
</where>
|
||||
GROUP BY <include refid="counterpartyNameExpr"/>
|
||||
) grouped
|
||||
<include refid="counterpartyOrderBy"/>
|
||||
</select>
|
||||
|
||||
<select id="selectStatementListForExport" resultMap="CcdiBankStatementListVOResultMap">
|
||||
SELECT
|
||||
<include refid="statementListColumns"/>
|
||||
|
||||
@@ -96,6 +96,35 @@
|
||||
<result property="involvedAmount" column="involved_amount"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="UnsubmittedAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountItemVO">
|
||||
<result property="subjectName" column="subjectName"/>
|
||||
<result property="accountNo" column="accountNo"/>
|
||||
<result property="totalAmountCr" column="totalAmountCr"/>
|
||||
<result property="totalAmountDr" column="totalAmountDr"/>
|
||||
<result property="netAmount" column="netAmount"/>
|
||||
<result property="inflowRatio" column="inflowRatio"/>
|
||||
<result property="outflowRatio" column="outflowRatio"/>
|
||||
<result property="highRatio" column="highRatio"/>
|
||||
<result property="transactionCount" column="transactionCount"/>
|
||||
<result property="startDate" column="startDate"/>
|
||||
<result property="endDate" column="endDate"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="UnsubmittedAccountFlowResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO">
|
||||
<result property="bankStatementId" column="bankStatementId"/>
|
||||
<result property="subjectName" column="subjectName"/>
|
||||
<result property="accountNo" column="accountNo"/>
|
||||
<result property="trxDate" column="trxDate"/>
|
||||
<result property="leAccountNo" column="leAccountNo"/>
|
||||
<result property="leAccountName" column="leAccountName"/>
|
||||
<result property="customerAccountName" column="customerAccountName"/>
|
||||
<result property="customerAccountNo" column="customerAccountNo"/>
|
||||
<result property="userMemo" column="userMemo"/>
|
||||
<result property="cashType" column="cashType"/>
|
||||
<result property="amountCr" column="amountCr"/>
|
||||
<result property="amountDr" column="amountDr"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="ReportUploadSubjectResultMap"
|
||||
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO">
|
||||
<result property="subjectName" column="subjectName"/>
|
||||
@@ -1545,6 +1574,196 @@
|
||||
order by abnormal.abnormal_time desc, abnormal.account_no asc, abnormal.rule_code asc
|
||||
</select>
|
||||
|
||||
<sql id="unsubmittedAccountLoanConditionSql">
|
||||
(
|
||||
bs.USER_MEMO like '%还贷%'
|
||||
or bs.USER_MEMO like '%还贷款%'
|
||||
or bs.USER_MEMO like '%贷款还款%'
|
||||
or bs.USER_MEMO like '%归还贷款%'
|
||||
or bs.USER_MEMO like '%贷款归还%'
|
||||
or bs.USER_MEMO like '%还本%'
|
||||
or bs.USER_MEMO like '%还息%'
|
||||
or bs.USER_MEMO like '%付息%'
|
||||
or bs.USER_MEMO like '%贷息%'
|
||||
or bs.USER_MEMO like '%贷款利息%'
|
||||
or bs.USER_MEMO like '%贷款发放%'
|
||||
or bs.USER_MEMO like '%普贷放款%'
|
||||
or bs.USER_MEMO like '%放款%'
|
||||
or bs.USER_MEMO like '%放贷%'
|
||||
or bs.USER_MEMO like '%银团贷%'
|
||||
or bs.USER_MEMO like '%流贷%'
|
||||
or bs.USER_MEMO like '%续贷%资金%'
|
||||
or bs.CASH_TYPE like '%贷款本息%'
|
||||
or bs.CASH_TYPE like '%贷款扣款%'
|
||||
or bs.CASH_TYPE like '%贷款回收%'
|
||||
or bs.CASH_TYPE like '%还贷款%'
|
||||
or bs.CASH_TYPE like '%归还本金%'
|
||||
or bs.CASH_TYPE like '%归还本息%'
|
||||
or bs.CASH_TYPE like '%归还利息%'
|
||||
or bs.CASH_TYPE like '%归还逾期利息%'
|
||||
or bs.CASH_TYPE like '%贷息%'
|
||||
or bs.CASH_TYPE like '%贷款放%'
|
||||
or bs.CASH_TYPE like '%放贷%'
|
||||
or bs.CASH_TYPE like '%信贷系统业务对公资产%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%个贷系统平账专户%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%企业贷%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%微粒贷%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%贷款还款户%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%贷款归还户%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%融资利息收入%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%短期贷款%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%科创贷%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%企金贷%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%网贷通%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%联合贷%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%数字信用凭据融资%'
|
||||
or bs.CUSTOMER_ACCOUNT_NAME like '%消费金融%'
|
||||
)
|
||||
</sql>
|
||||
|
||||
<sql id="unsubmittedAccountCandidateRowSql">
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
trim(bs.LE_ACCOUNT_NAME) as subjectName,
|
||||
replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '') as accountNo,
|
||||
bs.TRX_DATE as trxDate,
|
||||
bs.LE_ACCOUNT_NO as leAccountNo,
|
||||
bs.LE_ACCOUNT_NAME as leAccountName,
|
||||
bs.CUSTOMER_ACCOUNT_NAME as customerAccountName,
|
||||
bs.CUSTOMER_ACCOUNT_NO as customerAccountNo,
|
||||
bs.USER_MEMO as userMemo,
|
||||
bs.CASH_TYPE as cashType,
|
||||
ifnull(bs.AMOUNT_CR, 0) as amountCr,
|
||||
ifnull(bs.AMOUNT_DR, 0) as amountDr,
|
||||
case
|
||||
when <include refid="unsubmittedAccountLoanConditionSql"/> then 1
|
||||
else 0
|
||||
end as loanFlag
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{unsubmittedProjectId}
|
||||
and bs.LE_ACCOUNT_NAME is not null
|
||||
and trim(bs.LE_ACCOUNT_NAME) != ''
|
||||
and bs.CUSTOMER_ACCOUNT_NAME is not null
|
||||
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and replace(replace(trim(bs.CUSTOMER_ACCOUNT_NAME), '(', '('), ')', ')')
|
||||
= replace(replace(trim(bs.LE_ACCOUNT_NAME), '(', '('), ')', ')')
|
||||
and bs.CUSTOMER_ACCOUNT_NO is not null
|
||||
and replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '') != ''
|
||||
and replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '')
|
||||
!= replace(replace(replace(trim(ifnull(bs.LE_ACCOUNT_NO, '')), '-', ''), char(9), ''), ' ', '')
|
||||
and not exists (
|
||||
select 1
|
||||
from ccdi_bank_statement submitted
|
||||
where submitted.project_id = bs.project_id
|
||||
and submitted.LE_ACCOUNT_NO is not null
|
||||
and replace(replace(replace(trim(submitted.LE_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '')
|
||||
= replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '')
|
||||
)
|
||||
</sql>
|
||||
|
||||
<sql id="unsubmittedAccountAggregateSql">
|
||||
select
|
||||
candidate.subjectName,
|
||||
candidate.accountNo,
|
||||
sum(candidate.amountCr) as totalAmountCr,
|
||||
sum(candidate.amountDr) as totalAmountDr,
|
||||
sum(candidate.amountCr) - sum(candidate.amountDr) as netAmount,
|
||||
count(*) as transactionCount,
|
||||
min(candidate.trxDate) as startDate,
|
||||
max(candidate.trxDate) as endDate
|
||||
from (
|
||||
<include refid="unsubmittedAccountCandidateRowSql"/>
|
||||
) candidate
|
||||
group by candidate.subjectName, candidate.accountNo
|
||||
having sum(candidate.loanFlag) < count(*)
|
||||
</sql>
|
||||
|
||||
<sql id="unsubmittedAccountSubjectTotalSql">
|
||||
select
|
||||
trim(bs.LE_ACCOUNT_NAME) as subjectName,
|
||||
sum(ifnull(bs.AMOUNT_CR, 0)) as subjectAmountCr,
|
||||
sum(ifnull(bs.AMOUNT_DR, 0)) as subjectAmountDr
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{unsubmittedProjectId}
|
||||
and bs.LE_ACCOUNT_NAME is not null
|
||||
and trim(bs.LE_ACCOUNT_NAME) != ''
|
||||
group by trim(bs.LE_ACCOUNT_NAME)
|
||||
</sql>
|
||||
|
||||
<select id="selectUnsubmittedAccountList" resultMap="UnsubmittedAccountItemResultMap">
|
||||
<bind name="unsubmittedProjectId" value="query.projectId"/>
|
||||
select
|
||||
agg.subjectName,
|
||||
agg.accountNo,
|
||||
agg.totalAmountCr,
|
||||
agg.totalAmountDr,
|
||||
agg.netAmount,
|
||||
case
|
||||
when ifnull(subject_total.subjectAmountCr, 0) > 0
|
||||
then round(agg.totalAmountCr / subject_total.subjectAmountCr, 4)
|
||||
else 0
|
||||
end as inflowRatio,
|
||||
case
|
||||
when ifnull(subject_total.subjectAmountDr, 0) > 0
|
||||
then round(agg.totalAmountDr / subject_total.subjectAmountDr, 4)
|
||||
else 0
|
||||
end as outflowRatio,
|
||||
case
|
||||
when ifnull(subject_total.subjectAmountCr, 0) > 0
|
||||
and agg.totalAmountCr / subject_total.subjectAmountCr >= 0.1
|
||||
then 1
|
||||
when ifnull(subject_total.subjectAmountDr, 0) > 0
|
||||
and agg.totalAmountDr / subject_total.subjectAmountDr >= 0.1
|
||||
then 1
|
||||
else 0
|
||||
end as highRatio,
|
||||
agg.transactionCount,
|
||||
agg.startDate,
|
||||
agg.endDate
|
||||
from (
|
||||
<include refid="unsubmittedAccountAggregateSql"/>
|
||||
) agg
|
||||
left join (
|
||||
<include refid="unsubmittedAccountSubjectTotalSql"/>
|
||||
) subject_total on subject_total.subjectName = agg.subjectName
|
||||
order by agg.totalAmountCr + agg.totalAmountDr desc, agg.subjectName asc, agg.accountNo asc
|
||||
</select>
|
||||
|
||||
<select id="selectUnsubmittedAccountFlowList" resultMap="UnsubmittedAccountFlowResultMap">
|
||||
<bind name="unsubmittedProjectId" value="query.projectId"/>
|
||||
select
|
||||
candidate.bankStatementId,
|
||||
candidate.subjectName,
|
||||
candidate.accountNo,
|
||||
candidate.trxDate,
|
||||
candidate.leAccountNo,
|
||||
candidate.leAccountName,
|
||||
candidate.customerAccountName,
|
||||
candidate.customerAccountNo,
|
||||
candidate.userMemo,
|
||||
candidate.cashType,
|
||||
candidate.amountCr,
|
||||
candidate.amountDr
|
||||
from (
|
||||
<include refid="unsubmittedAccountCandidateRowSql"/>
|
||||
) candidate
|
||||
inner join (
|
||||
<include refid="unsubmittedAccountAggregateSql"/>
|
||||
) agg on agg.subjectName = candidate.subjectName
|
||||
and agg.accountNo = candidate.accountNo
|
||||
<where>
|
||||
<if test="query.subjectName != null and query.subjectName != ''">
|
||||
and replace(replace(trim(candidate.subjectName), '(', '('), ')', ')')
|
||||
= replace(replace(trim(#{query.subjectName}), '(', '('), ')', ')')
|
||||
</if>
|
||||
<if test="query.accountNo != null and query.accountNo != ''">
|
||||
and candidate.accountNo
|
||||
= replace(replace(replace(trim(#{query.accountNo}), '-', ''), char(9), ''), ' ', '')
|
||||
</if>
|
||||
</where>
|
||||
order by candidate.subjectName asc, candidate.accountNo asc, candidate.trxDate asc, candidate.bankStatementId asc
|
||||
</select>
|
||||
|
||||
<select id="selectRiskModelNamesByScope" resultType="java.lang.String">
|
||||
select
|
||||
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelName'))) as model_name
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.controller;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
@@ -80,6 +81,31 @@ class CcdiBankStatementControllerTest {
|
||||
assertEquals(1, result.getRows().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void counterpartyList_shouldReturnTableData() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("pageNum", "1");
|
||||
request.setParameter("pageSize", "10");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
|
||||
queryDTO.setProjectId(100L);
|
||||
CcdiBankStatementCounterpartyVO row = new CcdiBankStatementCounterpartyVO();
|
||||
row.setCustomerAccountName("测试对手方");
|
||||
row.setTotalCount(2L);
|
||||
Page<CcdiBankStatementCounterpartyVO> page = new Page<>(1, 10);
|
||||
page.setRecords(List.of(row));
|
||||
page.setTotal(1);
|
||||
when(bankStatementService.selectCounterpartyPage(any(), same(queryDTO))).thenReturn(page);
|
||||
|
||||
TableDataInfo result = controller.counterpartyList(queryDTO);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertEquals(1L, result.getTotal());
|
||||
assertEquals(1, result.getRows().size());
|
||||
verify(projectAccessService).assertCanRead(100L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detail_shouldReturnAjaxResultSuccess() {
|
||||
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -30,6 +31,7 @@ import static org.mockito.Mockito.when;
|
||||
class CcdiFileUploadControllerTest {
|
||||
|
||||
private static final Long PROJECT_ID = 100L;
|
||||
private static final CallerContext CALLER = CallerContext.of(9527L, "admin");
|
||||
|
||||
@InjectMocks
|
||||
private CcdiFileUploadController controller;
|
||||
@@ -72,7 +74,7 @@ class CcdiFileUploadControllerTest {
|
||||
};
|
||||
|
||||
setLoginUser(9527L, "admin");
|
||||
when(fileUploadService.batchUploadFiles(PROJECT_ID, files, "admin"))
|
||||
when(fileUploadService.batchUploadFiles(PROJECT_ID, files, CALLER))
|
||||
.thenReturn("batch-1");
|
||||
|
||||
AjaxResult result = controller.batchUpload(PROJECT_ID, files);
|
||||
@@ -80,7 +82,7 @@ class CcdiFileUploadControllerTest {
|
||||
assertEquals(200, result.get("code"));
|
||||
assertEquals("batch-1", result.get("data"));
|
||||
verify(projectAccessService).assertCanOperate(PROJECT_ID);
|
||||
verify(fileUploadService).batchUploadFiles(PROJECT_ID, files, "admin");
|
||||
verify(fileUploadService).batchUploadFiles(PROJECT_ID, files, CALLER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -93,7 +95,8 @@ class CcdiFileUploadControllerTest {
|
||||
dto.setEndDate("2026-03-10");
|
||||
|
||||
setLoginUser(9527L, "admin");
|
||||
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "ZJRCU", "2026-03-01", "2026-03-10", 9527L, "admin"))
|
||||
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "ZJRCU",
|
||||
"2026-03-01", "2026-03-10", CALLER))
|
||||
.thenReturn("batch-1");
|
||||
|
||||
AjaxResult result = controller.pullBankInfo(dto);
|
||||
@@ -109,7 +112,7 @@ class CcdiFileUploadControllerTest {
|
||||
dto.setDataChannelCode("JZL");
|
||||
|
||||
setLoginUser(9527L, "admin");
|
||||
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "JZL", null, null, 9527L, "admin"))
|
||||
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "JZL", null, null, CALLER))
|
||||
.thenReturn("batch-1");
|
||||
|
||||
AjaxResult result = controller.pullBankInfo(dto);
|
||||
|
||||
@@ -116,6 +116,27 @@ class CcdiBankStatementMapperXmlTest {
|
||||
assertTrue(sql.contains("END ) <= ?"), sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectCounterpartyPage_shouldGroupByTrimmedCounterpartyNameAndReuseFilters() throws Exception {
|
||||
MappedStatement mappedStatement = loadMappedStatement(
|
||||
"com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper.selectCounterpartyPage");
|
||||
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
|
||||
queryDTO.setProjectId(33L);
|
||||
queryDTO.setTabType("out");
|
||||
queryDTO.setCounterpartyName("杭州");
|
||||
queryDTO.setOrderBy("totalAmount");
|
||||
queryDTO.setOrderDirection("desc");
|
||||
|
||||
String sql = renderSql(mappedStatement, queryDTO);
|
||||
|
||||
assertTrue(sql.contains("TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) AS customerAccountName"), sql);
|
||||
assertTrue(sql.contains("GROUP BY TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))"), sql);
|
||||
assertTrue(sql.contains("(bs.project_id = ?)"), sql);
|
||||
assertTrue(sql.contains("IFNULL(bs.AMOUNT_DR, 0) > 0"), sql);
|
||||
assertTrue(sql.contains("bs.CUSTOMER_ACCOUNT_NAME LIKE CONCAT('%', TRIM(?), '%')"), sql);
|
||||
assertTrue(sql.contains("ORDER BY totalAmount DESC, totalCount DESC, customerAccountName ASC"), sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectStatementDetailById_shouldJoinUploadRecordForOriginalFileMetadata() throws Exception {
|
||||
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
|
||||
|
||||
@@ -238,6 +238,49 @@ class CcdiProjectOverviewMapperSqlTest {
|
||||
assertTrue(xml.contains("as involved_amount"), xml);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeUnsubmittedAccountQueriesWithLoanExclusion() throws Exception {
|
||||
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
|
||||
String accountSql = extractSelect(xml, "selectUnsubmittedAccountList");
|
||||
String flowSql = extractSelect(xml, "selectUnsubmittedAccountFlowList");
|
||||
String candidateSql = extractSqlFragment(xml, "unsubmittedAccountCandidateRowSql");
|
||||
String loanSql = extractSqlFragment(xml, "unsubmittedAccountLoanConditionSql");
|
||||
String aggregateSql = extractSqlFragment(xml, "unsubmittedAccountAggregateSql");
|
||||
|
||||
assertTrue(accountSql.contains("unsubmittedAccountAggregateSql"), accountSql);
|
||||
assertTrue(accountSql.contains("unsubmittedAccountSubjectTotalSql"), accountSql);
|
||||
assertTrue(flowSql.contains("unsubmittedAccountCandidateRowSql"), flowSql);
|
||||
assertTrue(flowSql.contains("unsubmittedAccountAggregateSql"), flowSql);
|
||||
assertTrue(flowSql.contains("query.projectId"), flowSql);
|
||||
assertTrue(flowSql.contains("query.subjectName"), flowSql);
|
||||
assertTrue(flowSql.contains("query.accountNo"), flowSql);
|
||||
assertTrue(flowSql.contains("candidate.subjectName"), flowSql);
|
||||
assertTrue(flowSql.contains("candidate.accountNo"), flowSql);
|
||||
assertTrue(candidateSql.contains("bs.project_id = #{unsubmittedProjectId}"), candidateSql);
|
||||
assertTrue(candidateSql.contains("bs.CUSTOMER_ACCOUNT_NAME"), candidateSql);
|
||||
assertTrue(candidateSql.contains("bs.LE_ACCOUNT_NAME"), candidateSql);
|
||||
assertTrue(candidateSql.contains("not exists"), candidateSql);
|
||||
assertTrue(candidateSql.contains("submitted.LE_ACCOUNT_NO"), candidateSql);
|
||||
assertFalse(candidateSql.contains("TRX_TYPE"), candidateSql);
|
||||
|
||||
assertTrue(loanSql.contains("还贷"), loanSql);
|
||||
assertTrue(loanSql.contains("贷款还款"), loanSql);
|
||||
assertTrue(loanSql.contains("归还贷款"), loanSql);
|
||||
assertTrue(loanSql.contains("贷款发放"), loanSql);
|
||||
assertTrue(loanSql.contains("普贷放款"), loanSql);
|
||||
assertTrue(loanSql.contains("信贷系统业务对公资产"), loanSql);
|
||||
assertTrue(loanSql.contains("个贷系统平账专户"), loanSql);
|
||||
assertTrue(loanSql.contains("企业贷"), loanSql);
|
||||
assertTrue(loanSql.contains("消费金融"), loanSql);
|
||||
assertFalse(loanSql.contains("民间借贷"), loanSql);
|
||||
assertFalse(loanSql.contains("资金拆借"), loanSql);
|
||||
assertFalse(loanSql.contains("调拨"), loanSql);
|
||||
assertFalse(loanSql.contains("划拨"), loanSql);
|
||||
assertFalse(loanSql.contains("TRX_TYPE"), loanSql);
|
||||
|
||||
assertTrue(aggregateSql.contains("having sum(candidate.loanFlag) < count(*)"), aggregateSql);
|
||||
}
|
||||
|
||||
private String extractSelect(String xml, String selectId) {
|
||||
String start = "<select id=\"" + selectId + "\"";
|
||||
int startIndex = xml.indexOf(start);
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
|
||||
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
|
||||
import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse;
|
||||
@@ -34,6 +35,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -63,6 +65,7 @@ import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CcdiFileUploadServiceImplTest {
|
||||
@@ -71,6 +74,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
private static final Integer LSFX_PROJECT_ID = 200;
|
||||
private static final Long RECORD_ID = 300L;
|
||||
private static final Integer LOG_ID = 400;
|
||||
private static final CallerContext CALLER = CallerContext.of(9527L, "admin");
|
||||
private static final int MAX_ERROR_MESSAGE_LENGTH = 2000;
|
||||
|
||||
@InjectMocks
|
||||
@@ -149,8 +153,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
LsfxConstants.DATA_CHANNEL_ZJRCU,
|
||||
"2026-03-01",
|
||||
"2026-03-10",
|
||||
9527L,
|
||||
"admin"
|
||||
CALLER
|
||||
);
|
||||
|
||||
assertNotNull(batchId);
|
||||
@@ -177,8 +180,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
LsfxConstants.DATA_CHANNEL_ZJRCU,
|
||||
"2026-01-01",
|
||||
"2026-01-31",
|
||||
1L,
|
||||
"tester"
|
||||
CALLER
|
||||
));
|
||||
}
|
||||
|
||||
@@ -195,7 +197,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
);
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
|
||||
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -222,7 +224,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
String batchId = service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester");
|
||||
String batchId = service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER);
|
||||
|
||||
assertNotNull(batchId);
|
||||
assertNotNull(inserted.get());
|
||||
@@ -251,12 +253,12 @@ class CcdiFileUploadServiceImplTest {
|
||||
);
|
||||
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
|
||||
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER));
|
||||
|
||||
assertTrue(exception.getMessage().contains("身份证"));
|
||||
assertFalse(Files.exists(tempDir.resolve("temp")));
|
||||
verify(recordMapper, never()).insertBatch(any());
|
||||
verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
|
||||
verify(lsfxClient, never()).uploadFile(any(), any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -272,12 +274,12 @@ class CcdiFileUploadServiceImplTest {
|
||||
);
|
||||
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
|
||||
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER));
|
||||
|
||||
assertTrue(exception.getMessage().contains("文件名不能为空"));
|
||||
assertFalse(Files.exists(tempDir.resolve("temp")));
|
||||
verify(recordMapper, never()).insertBatch(any());
|
||||
verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
|
||||
verify(lsfxClient, never()).uploadFile(any(), any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -293,6 +295,29 @@ class CcdiFileUploadServiceImplTest {
|
||||
assertFalse(Files.exists(batchLogDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitTasksAsync_shouldKeepEachCapturedCallerAfterSecurityContextIsCleared() throws Exception {
|
||||
setField("fileUploadExecutor", (Executor) Runnable::run);
|
||||
CallerContext callerA = CallerContext.of(101L, "userA");
|
||||
CallerContext callerB = CallerContext.of(202L, "userB");
|
||||
Path fileA = createTempFile();
|
||||
Path fileB = createTempFile();
|
||||
CcdiFileUploadRecord recordA = buildRecord();
|
||||
CcdiFileUploadRecord recordB = buildRecord();
|
||||
recordB.setId(RECORD_ID + 1);
|
||||
when(lsfxClient.uploadFile(any(CallerContext.class), eq(LSFX_PROJECT_ID), any(), any()))
|
||||
.thenThrow(new RuntimeException("stop after caller capture"));
|
||||
|
||||
SecurityContextHolder.clearContext();
|
||||
invokeSubmitTasksAsync(List.of(fileA.toString()), List.of(recordA), "batch-a", callerA);
|
||||
invokeSubmitTasksAsync(List.of(fileB.toString()), List.of(recordB), "batch-b", callerB);
|
||||
|
||||
ArgumentCaptor<CallerContext> callerCaptor = ArgumentCaptor.forClass(CallerContext.class);
|
||||
verify(lsfxClient, org.mockito.Mockito.times(2)).uploadFile(
|
||||
callerCaptor.capture(), eq(LSFX_PROJECT_ID), any(), any());
|
||||
assertEquals(List.of(callerA, callerB), callerCaptor.getAllValues());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleTagRebuildAfterBatchCompletion_shouldLogSkipWhenAllRecordsFailed() {
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(CcdiFileUploadServiceImpl.class);
|
||||
@@ -352,18 +377,18 @@ class CcdiFileUploadServiceImplTest {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
captureRecordStatus(events, sequence);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenThrow(new RuntimeException("bank statement fetch failed"));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed")));
|
||||
assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success")));
|
||||
@@ -379,12 +404,12 @@ class CcdiFileUploadServiceImplTest {
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
|
||||
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
events.add(sequence.incrementAndGet() + ":bank-fetch");
|
||||
return buildEmptyBankStatementResponse();
|
||||
@@ -393,7 +418,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
int fetchIndex = findEventIndex(events, "bank-fetch");
|
||||
int successIndex = findEventIndex(events, "record:parsed_success");
|
||||
@@ -406,18 +431,18 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenThrow(new RuntimeException("bank statement fetch failed"));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
|
||||
}
|
||||
@@ -427,11 +452,11 @@ class CcdiFileUploadServiceImplTest {
|
||||
GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("XX身份证.xlsx");
|
||||
statusResponse.getData().getLogs().get(0).setFileSize(2048L);
|
||||
|
||||
when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.fetchInnerFlow(eq(CALLER), any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(statusResponse);
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
@@ -444,7 +469,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
"110101199001018888",
|
||||
LsfxConstants.DATA_CHANNEL_ZJRCU,
|
||||
"2026-03-01",
|
||||
"2026-03-10"
|
||||
"2026-03-10",
|
||||
CALLER
|
||||
);
|
||||
|
||||
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
|
||||
@@ -457,11 +483,11 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void processPullBankInfoAsync_shouldFetchJzlWithZeroDateRange() {
|
||||
when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.fetchInnerFlow(eq(CALLER), any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
@@ -473,10 +499,11 @@ class CcdiFileUploadServiceImplTest {
|
||||
"110101199001018888",
|
||||
LsfxConstants.DATA_CHANNEL_JZL,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
CALLER
|
||||
);
|
||||
|
||||
verify(lsfxClient).fetchInnerFlow(argThat((FetchInnerFlowRequest request) ->
|
||||
verify(lsfxClient).fetchInnerFlow(eq(CALLER), argThat((FetchInnerFlowRequest request) ->
|
||||
LsfxConstants.DATA_CHANNEL_JZL.equals(request.getDataChannelCode())
|
||||
&& Integer.valueOf(0).equals(request.getDataStartDateId())
|
||||
&& Integer.valueOf(0).equals(request.getDataEndDateId())
|
||||
@@ -485,21 +512,21 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file ->
|
||||
verify(lsfxClient).uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), argThat(file ->
|
||||
file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx")
|
||||
), eq("原始流水.xlsx"));
|
||||
}
|
||||
@@ -517,14 +544,15 @@ class CcdiFileUploadServiceImplTest {
|
||||
project.setProjectId(PROJECT_ID);
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
|
||||
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("张三_330101199001010011_流水.xlsx")))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(),
|
||||
eq("张三_330101199001010011_流水.xlsx")))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
GetBankStatementRequest request = invocation.getArgument(0);
|
||||
GetBankStatementRequest request = invocation.getArgument(1);
|
||||
if (Integer.valueOf(1).equals(request.getPageSize())) {
|
||||
return buildBankStatementCountResponse(1);
|
||||
}
|
||||
@@ -535,7 +563,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
record.setFileName("张三_330101199001010011_流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record,
|
||||
CALLER);
|
||||
|
||||
assertNotNull(insertedStatements.get());
|
||||
assertEquals(1, insertedStatements.get().size());
|
||||
@@ -544,20 +573,20 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any()))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any()))
|
||||
.thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx"));
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
|
||||
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
@@ -573,17 +602,17 @@ class CcdiFileUploadServiceImplTest {
|
||||
logItem.setStatus(-1);
|
||||
logItem.setUploadStatusDesc("parse.failed");
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(statusResponse);
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
|
||||
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
@@ -610,7 +639,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
|
||||
|
||||
assertEquals("删除成功,已开始项目重新打标", result);
|
||||
verify(lsfxClient, never()).deleteFiles(any());
|
||||
verify(lsfxClient, never()).deleteFiles(any(), any());
|
||||
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
|
||||
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
|
||||
@@ -644,7 +673,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
|
||||
|
||||
assertTrue(exception.getMessage().contains("历史导入文件不支持删除"));
|
||||
verify(lsfxClient, never()).deleteFiles(any());
|
||||
verify(lsfxClient, never()).deleteFiles(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -659,7 +688,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
|
||||
|
||||
assertEquals("删除成功,已开始项目重新打标", result);
|
||||
verify(lsfxClient, never()).deleteFiles(any());
|
||||
verify(lsfxClient, never()).deleteFiles(any(), any());
|
||||
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
|
||||
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
"deleted".equals(item.getFileStatus())
|
||||
@@ -669,7 +698,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
// @Test
|
||||
// void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() {
|
||||
// when(lsfxClient.fetchInnerFlow(any())).thenThrow(new RuntimeException("fetch inner flow failed"));
|
||||
// when(lsfxClient.fetchInnerFlow(eq(CALLER), any())).thenThrow(new RuntimeException("fetch inner flow failed"));
|
||||
//
|
||||
// CcdiFileUploadRecord record = buildRecord();
|
||||
// service.processPullBankInfoAsync(
|
||||
@@ -692,19 +721,19 @@ class CcdiFileUploadServiceImplTest {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
captureRecordStatus(events, sequence);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildBankStatementResponseWithTotalCount(1))
|
||||
.thenThrow(new RuntimeException("paged fetch failed"));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed")));
|
||||
assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success")));
|
||||
@@ -716,18 +745,18 @@ class CcdiFileUploadServiceImplTest {
|
||||
List<CcdiFileUploadRecord> updates = new ArrayList<>();
|
||||
captureUpdatedRecords(updates);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenThrow(new RuntimeException("bank statement fetch failed:" + "x".repeat(3000)));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
CcdiFileUploadRecord failedRecord = findLastUpdatedRecordByStatus(updates, "parsed_failed");
|
||||
assertTrue(failedRecord.getErrorMessage().length() <= MAX_ERROR_MESSAGE_LENGTH);
|
||||
@@ -738,13 +767,13 @@ class CcdiFileUploadServiceImplTest {
|
||||
List<CcdiFileUploadRecord> updates = new ArrayList<>();
|
||||
captureUpdatedRecords(updates);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenThrow(new RuntimeException("upload failed:" + "x".repeat(3000)));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
CcdiFileUploadRecord failedRecord = findLastUpdatedRecordByStatus(updates, "parsed_failed");
|
||||
assertTrue(failedRecord.getErrorMessage().length() <= MAX_ERROR_MESSAGE_LENGTH);
|
||||
@@ -752,19 +781,19 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void fetchAndSaveBankStatements_shouldTrimLeAccountNoBeforeInsert() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem(" 62220001 "))))
|
||||
.thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem(" 62220001 "))));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
verify(bankStatementMapper).insertBatch(any());
|
||||
verify(bankStatementMapper).insertBatch(org.mockito.ArgumentMatchers.argThat(list ->
|
||||
@@ -773,7 +802,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void fetchAndSaveBankStatements_shouldLogConservativeCountsWhenAffectedRowsAreAmbiguous() {
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))))
|
||||
.thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))));
|
||||
when(bankStatementMapper.insertBatch(any())).thenReturn(1);
|
||||
@@ -787,6 +816,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
Object result = ReflectionTestUtils.invokeMethod(
|
||||
service,
|
||||
"fetchAndSaveBankStatements",
|
||||
CALLER,
|
||||
PROJECT_ID,
|
||||
LSFX_PROJECT_ID,
|
||||
LOG_ID,
|
||||
@@ -825,12 +855,12 @@ class CcdiFileUploadServiceImplTest {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
captureRecordStatus(events, sequence);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))))
|
||||
.thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))));
|
||||
when(bankStatementMapper.insertBatch(any()))
|
||||
@@ -839,7 +869,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER);
|
||||
|
||||
assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed")));
|
||||
assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success")));
|
||||
@@ -1026,10 +1056,17 @@ class CcdiFileUploadServiceImplTest {
|
||||
private void invokeSubmitTasksAsync(List<String> tempFilePaths,
|
||||
List<CcdiFileUploadRecord> records,
|
||||
String batchId) throws Exception {
|
||||
invokeSubmitTasksAsync(tempFilePaths, records, batchId, CALLER);
|
||||
}
|
||||
|
||||
private void invokeSubmitTasksAsync(List<String> tempFilePaths,
|
||||
List<CcdiFileUploadRecord> records,
|
||||
String batchId,
|
||||
CallerContext caller) throws Exception {
|
||||
Method method = CcdiFileUploadServiceImpl.class.getDeclaredMethod("submitTasksAsync",
|
||||
Long.class, Integer.class, List.class, List.class, String.class);
|
||||
Long.class, Integer.class, List.class, List.class, String.class, CallerContext.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(service, PROJECT_ID, LSFX_PROJECT_ID, tempFilePaths, records, batchId);
|
||||
method.invoke(service, PROJECT_ID, LSFX_PROJECT_ID, tempFilePaths, records, batchId, caller);
|
||||
}
|
||||
|
||||
private void setField(String fieldName, Object value) throws Exception {
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
|
||||
@@ -322,6 +323,9 @@ class CcdiProjectOverviewServiceImplTest {
|
||||
&& "6222000000000001".equals(rows.getFirst().getAccountNo())
|
||||
&& "账户6222000000000001销户前存在异常交易".equals(rows.getFirst().getReasonDetail())
|
||||
&& new BigDecimal("120000.00").equals(rows.getFirst().getInvolvedAmount())
|
||||
),
|
||||
argThat((List<CcdiProjectUnsubmittedAccountFlowExcel> rows) ->
|
||||
rows != null && rows.isEmpty()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package com.ruoyi.ccdi.project.service.impl;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@@ -17,7 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class CcdiProjectRiskDetailWorkbookExporterTest {
|
||||
|
||||
@Test
|
||||
void shouldExportWorkbookWithThreeOrderedSheets() throws Exception {
|
||||
void shouldExportWorkbookWithFourOrderedSheets() throws Exception {
|
||||
CcdiProjectRiskDetailWorkbookExporter exporter = new CcdiProjectRiskDetailWorkbookExporter();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
@@ -50,17 +52,38 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
|
||||
abnormalRow.setReasonDetail("账户6222000000000001销户前存在异常交易");
|
||||
abnormalRow.setInvolvedAmount(new BigDecimal("120000.00"));
|
||||
|
||||
exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow), List.of(abnormalRow));
|
||||
CcdiProjectUnsubmittedAccountFlowExcel unsubmittedRow = new CcdiProjectUnsubmittedAccountFlowExcel();
|
||||
unsubmittedRow.setSubjectName("测试主体");
|
||||
unsubmittedRow.setAccountNo("330001000000000001");
|
||||
unsubmittedRow.setTrxDate("2026-03-21");
|
||||
unsubmittedRow.setLeAccountNo("330001000000000002");
|
||||
unsubmittedRow.setLeAccountName("测试主体");
|
||||
unsubmittedRow.setCustomerAccountName("测试主体");
|
||||
unsubmittedRow.setCustomerAccountNo("330001000000000001");
|
||||
unsubmittedRow.setUserMemo("往来款");
|
||||
unsubmittedRow.setCashType("转账");
|
||||
unsubmittedRow.setAmountCr(new BigDecimal("30000.00"));
|
||||
unsubmittedRow.setAmountDr(BigDecimal.ZERO);
|
||||
|
||||
exporter.export(
|
||||
response,
|
||||
40L,
|
||||
List.of(suspiciousRow),
|
||||
List.of(creditRow),
|
||||
List.of(abnormalRow),
|
||||
List.of(unsubmittedRow)
|
||||
);
|
||||
|
||||
assertTrue(response.getContentType().startsWith(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
));
|
||||
|
||||
try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
|
||||
assertEquals(3, workbook.getNumberOfSheets());
|
||||
assertEquals(4, workbook.getNumberOfSheets());
|
||||
assertEquals("涉疑交易明细", workbook.getSheetAt(0).getSheetName());
|
||||
assertEquals("员工负面征信信息", workbook.getSheetAt(1).getSheetName());
|
||||
assertEquals("异常账户人员信息", workbook.getSheetAt(2).getSheetName());
|
||||
assertEquals("未提交账户流水", workbook.getSheetAt(3).getSheetName());
|
||||
assertEquals("交易时间", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("本方账户", workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue());
|
||||
assertEquals("本方主体", workbook.getSheetAt(0).getRow(0).getCell(2).getStringCellValue());
|
||||
@@ -69,11 +92,12 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
|
||||
assertEquals("关联员工", workbook.getSheetAt(0).getRow(0).getCell(5).getStringCellValue());
|
||||
assertEquals("摘要", workbook.getSheetAt(0).getRow(0).getCell(6).getStringCellValue());
|
||||
assertEquals("交易类型", workbook.getSheetAt(0).getRow(0).getCell(7).getStringCellValue());
|
||||
assertEquals("异常标签", workbook.getSheetAt(0).getRow(0).getCell(8).getStringCellValue());
|
||||
assertEquals("交易金额", workbook.getSheetAt(0).getRow(0).getCell(9).getStringCellValue());
|
||||
assertEquals("交易金额", workbook.getSheetAt(0).getRow(0).getCell(8).getStringCellValue());
|
||||
assertEquals("异常标签", workbook.getSheetAt(0).getRow(0).getCell(9).getStringCellValue());
|
||||
assertEquals("测试对手方", workbook.getSheetAt(0).getRow(1).getCell(3).getStringCellValue());
|
||||
assertEquals("6222000000000001", workbook.getSheetAt(0).getRow(1).getCell(4).getStringCellValue());
|
||||
assertEquals("异常标签", workbook.getSheetAt(0).getRow(1).getCell(8).getStringCellValue());
|
||||
assertEquals(100D, workbook.getSheetAt(0).getRow(1).getCell(8).getNumericCellValue());
|
||||
assertEquals("异常标签", workbook.getSheetAt(0).getRow(1).getCell(9).getStringCellValue());
|
||||
assertEquals("账号", workbook.getSheetAt(2).getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("开户人", workbook.getSheetAt(2).getRow(0).getCell(1).getStringCellValue());
|
||||
assertEquals("银行", workbook.getSheetAt(2).getRow(0).getCell(2).getStringCellValue());
|
||||
@@ -91,6 +115,34 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
|
||||
assertEquals("账户6222000000000001销户前存在异常交易", workbook.getSheetAt(2).getRow(1).getCell(6).getStringCellValue());
|
||||
assertEquals(120000D, workbook.getSheetAt(2).getRow(1).getCell(7).getNumericCellValue());
|
||||
assertEquals(2, workbook.getSheetAt(2).getPhysicalNumberOfRows());
|
||||
assertEquals("主体名称", workbook.getSheetAt(3).getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("未提交账号", workbook.getSheetAt(3).getRow(0).getCell(1).getStringCellValue());
|
||||
assertEquals("交易时间", workbook.getSheetAt(3).getRow(0).getCell(2).getStringCellValue());
|
||||
assertEquals("流入金额", workbook.getSheetAt(3).getRow(0).getCell(9).getStringCellValue());
|
||||
assertEquals("测试主体", workbook.getSheetAt(3).getRow(1).getCell(0).getStringCellValue());
|
||||
assertEquals("330001000000000001", workbook.getSheetAt(3).getRow(1).getCell(1).getStringCellValue());
|
||||
assertEquals(30000D, workbook.getSheetAt(3).getRow(1).getCell(9).getNumericCellValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepSuspiciousTransactionExcelAmountBeforeHitTags() {
|
||||
Field[] fields = CcdiProjectSuspiciousTransactionExcel.class.getDeclaredFields();
|
||||
|
||||
int amountIndex = findFieldIndex(fields, "displayAmount");
|
||||
int hitTagsIndex = findFieldIndex(fields, "hitTags");
|
||||
|
||||
assertTrue(amountIndex >= 0);
|
||||
assertTrue(hitTagsIndex >= 0);
|
||||
assertTrue(amountIndex < hitTagsIndex);
|
||||
}
|
||||
|
||||
private int findFieldIndex(Field[] fields, String fieldName) {
|
||||
for (int i = 0; i < fields.length; i++) {
|
||||
if (fieldName.equals(fields[i].getName())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
|
||||
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
|
||||
import com.ruoyi.lsfx.domain.CallerContext;
|
||||
import com.ruoyi.lsfx.domain.response.GetTokenResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -48,6 +49,8 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CcdiProjectServiceImplTest {
|
||||
|
||||
private static final CallerContext CALLER = CallerContext.of(7L, "tester");
|
||||
|
||||
@InjectMocks
|
||||
private CcdiProjectServiceImpl service;
|
||||
|
||||
@@ -282,7 +285,7 @@ class CcdiProjectServiceImplTest {
|
||||
dto.setStartDate("2026-01-01");
|
||||
dto.setEndDate("2026-01-31");
|
||||
|
||||
when(lsfxAnalysisClient.getToken(any())).thenReturn(buildTokenResponse(3001));
|
||||
when(lsfxAnalysisClient.getToken(any(CallerContext.class), any())).thenReturn(buildTokenResponse(3001));
|
||||
doAnswer(invocation -> {
|
||||
CcdiProject project = invocation.getArgument(0);
|
||||
project.setProjectId(90L);
|
||||
@@ -291,7 +294,7 @@ class CcdiProjectServiceImplTest {
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
CcdiProjectVO project = service.importFromHistory(dto, "tester");
|
||||
CcdiProjectVO project = service.importFromHistory(dto, CALLER);
|
||||
|
||||
assertNotNull(project);
|
||||
assertEquals(90L, project.getProjectId());
|
||||
@@ -320,7 +323,7 @@ class CcdiProjectServiceImplTest {
|
||||
dto.setDescription("测试项目");
|
||||
dto.setConfigType("default");
|
||||
|
||||
when(lsfxAnalysisClient.getToken(any())).thenReturn(buildTokenResponse(2001));
|
||||
when(lsfxAnalysisClient.getToken(any(CallerContext.class), any())).thenReturn(buildTokenResponse(2001));
|
||||
doAnswer(invocation -> {
|
||||
CcdiProject project = invocation.getArgument(0);
|
||||
project.setProjectId(88L);
|
||||
@@ -333,7 +336,7 @@ class CcdiProjectServiceImplTest {
|
||||
logger.addAppender(logAppender);
|
||||
|
||||
try {
|
||||
service.createProject(dto);
|
||||
service.createProject(dto, CALLER);
|
||||
|
||||
assertTrue(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
|
||||
.anyMatch(message -> message.contains("项目状态初始化")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 流水明细对手方聚合查询后端实施计划
|
||||
|
||||
## 背景
|
||||
|
||||
明细查询需要增加类似 lsfx 对手方页签的聚合视图,按流水 `CUSTOMER_ACCOUNT_NAME` 归集同一项目、同一筛选条件下的交易数据,供前端展示对手方汇总。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- `ccdi-project` 后端模块。
|
||||
- 新增对手方聚合分页接口,不新增业务表,不修改已有流水明细查询入参结构。
|
||||
- 复用 `CcdiBankStatementQueryDTO` 的项目、交易方向、交易时间、本方主体、本方银行、本方账号、金额区间、对方名称、对方账户、摘要和空值匹配筛选。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 新增 `CcdiBankStatementCounterpartyVO`,返回对手方名称、空值标识、关联账号数、流入/流出金额与笔数、总交易额、总笔数、最近交易时间。
|
||||
2. 在 `CcdiBankStatementController` 增加 `GET /ccdi/project/bank-statement/counterparty/list`,沿用项目读权限校验和 RuoYi 分页返回结构。
|
||||
3. 在 `ICcdiBankStatementService` 与 `CcdiBankStatementServiceImpl` 增加聚合查询服务方法,统一复用明细查询参数归一化和排序白名单。
|
||||
4. 在 `CcdiBankStatementMapper` 与 XML 中新增聚合 SQL:
|
||||
- 对 `CUSTOMER_ACCOUNT_NAME` 做 `TRIM(IFNULL(..., ''))` 后分组。
|
||||
- 用 `CASE WHEN AMOUNT >= 0` 统计流入,用 `< 0` 统计流出。
|
||||
- 用 `ABS(AMOUNT)` 计算流出合计和总交易额,便于前端展示正数合计。
|
||||
- 最近交易时间复用既有交易时间解析表达式。
|
||||
5. 补充 Controller 与 Mapper XML 单元测试,覆盖接口分页返回、SQL 聚合字段、筛选条件和排序。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 运行后端定向单测:
|
||||
`mvn -pl ccdi-project -am "-Dtest=CcdiBankStatementMapperXmlTest,CcdiBankStatementControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`
|
||||
- 打包并启动真实后端后,用真实页面请求新接口,确认 `counterparty/list` 返回 200。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 征信本地文件清理后端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
征信 HTML 上传后仅作为外部解析接口的临时输入。解析调用结束后立即删除本地文件,避免成功或失败流程残留敏感征信文件。
|
||||
|
||||
## 实施内容
|
||||
|
||||
1. 在 `CreditHtmlStorageService` 增加受限删除能力,仅允许删除 `ruoyi.profile/credit-html` 目录内的文件。
|
||||
2. 在征信上传单文件流程中,解析成功后先删除本地文件,再校验解析结果并入库。
|
||||
3. 解析失败时仍执行删除;解析与删除同时失败时保留原始解析错误,并记录删除异常。
|
||||
4. 删除失败时终止当前文件后续处理,由现有批量上传逻辑记录失败并继续处理其他文件。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 仅修改征信维护上传的后端处理流程。
|
||||
- 不修改上传接口、返回结构、数据库和前端页面。
|
||||
- `/lsfx/credit/parse` 外部地址测试接口不管理本地上传文件,保持不变。
|
||||
|
||||
## 验证
|
||||
|
||||
- 覆盖成功、解析失败、结果校验失败、入库失败和删除失败场景。
|
||||
- 验证删除路径不能逃逸 `ruoyi.profile/credit-html`。
|
||||
- 运行征信维护定向测试及 `ccdi-info-collection` 模块测试。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 业务外部接口日志后端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
在不改变业务外部接口调用结果的前提下,统一记录流水分析与征信解析请求的发起用户、完整请求、原始响应、HTTP状态、耗时和异常,并提供接口日志分页查询与详情接口。
|
||||
|
||||
## 实施内容
|
||||
|
||||
1. 新建`sys_api_log`,请求头、请求参数、响应头、响应正文和异常使用`LONGTEXT`,表和字符字段统一使用`utf8mb4_general_ci`。
|
||||
2. 在`ruoyi-system`新增独立Entity、查询DTO、列表VO、详情VO、Mapper和事务Service;列表SQL不读取大字段,详情按日志ID读取完整内容。
|
||||
3. 在`ruoyi-admin`新增`/monitor/apilog/list`与`/monitor/apilog/{logId}`,分别校验`monitor:apilog:list`和`monitor:apilog:query`权限。
|
||||
4. 在`ccdi-lsfx`新增不可变`CallerContext`,替换流水分析、征信解析和`HttpUtil`原有调用签名,所有调用方必须显式传入发起人。
|
||||
5. 批量上传与拉取本行信息在提交异步任务前生成调用用户快照,并贯穿上传、拉取、轮询、状态查询和流水分页查询全链路。
|
||||
6. `HttpUtil`统一获取原始响应文本后再反序列化;非2xx保存状态、响应头和错误正文,网络异常和解析异常同样保存失败日志。
|
||||
7. 认证字段按原文保存;multipart文件仅保存文件名、大小和媒体类型,不读取或保存文件内容。
|
||||
8. 日志写入使用`REQUIRES_NEW`事务;日志序列化或入库失败只记录应用错误,不覆盖原业务结果。
|
||||
9. 通过增量SQL幂等创建表、接口日志菜单和查询权限按钮,不自动写入`sys_role_menu`。
|
||||
10. 流水分析和征信解析测试控制器取消匿名访问,统一校验`ccdi:project:edit`,人工测试调用按当前登录用户记录,不再记为`system`。
|
||||
|
||||
## 验证标准
|
||||
|
||||
- JDK 21下后端主工程编译通过。
|
||||
- 全部`HttpUtil`调用类型均能保存成功或失败日志,原始响应字段不因DTO反序列化丢失。
|
||||
- 异步任务在线程安全上下文清理后仍保存最初发起用户,不同用户连续任务不串号。
|
||||
- 未授权用户不能访问接口日志列表和详情。
|
||||
- 日志入库失败不影响外部接口返回或原始业务异常。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 未提交账户后端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
在结果总览风险明细中新增“未提交账户”能力,参考 lsfx 未提交账户口径识别本项目已上传流水中疑似未提交分析的同名账户,并按已确认范围排除银行贷款/信贷类账号。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- 新增未提交账户查询接口,返回汇总、主体分组和账号卡片数据。
|
||||
- 新增未提交账户导出接口,导出未提交账户流水。
|
||||
- 风险明细统一导出增加“未提交账户流水” Sheet。
|
||||
- 结果总览报告增加未提交账户明细。
|
||||
- 不新增数据库表,不新增菜单与权限。
|
||||
|
||||
## 关键口径
|
||||
|
||||
1. 候选账号来自 `ccdi_bank_statement` 当前项目流水。
|
||||
2. 对手方户名等于本方主体名,对手方账号非空,且清洗后不等于本方账号。
|
||||
3. 对手方账号不在本项目已提交账号集合中,已提交账号集合来自当前项目已上传流水的本方账号 `LE_ACCOUNT_NO`。
|
||||
4. 贷款排除只使用银行贷款/信贷高确定性关键词,不使用 `TRX_TYPE`。
|
||||
5. 只有候选账号关联流水全部命中贷款/信贷排除条件时,才排除该账号。
|
||||
|
||||
## 验证
|
||||
|
||||
- Mapper XML 断言覆盖未提交账户 SQL、贷款排除关键词与“不使用 TRX_TYPE”。
|
||||
- 风险明细工作簿测试覆盖新增 Sheet。
|
||||
- 后端编译或相关测试通过后记录实施结果。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 流水明细对手方聚合查询前端实施计划
|
||||
|
||||
## 背景
|
||||
|
||||
用户希望在项目详情的“流水明细查询”中增加“对手方”视图,效果参考 lsfx 对手方列表:在同一筛选条件下按对手方汇总,查看流入、流出和总交易规模。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
|
||||
- `ruoyi-ui/src/api/ccdiProjectBankStatement.js`
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 在流水明细结果区标题旁增加 `el-radio-group`,提供“流水明细 / 对手方”切换。
|
||||
2. 新增 `listBankStatementCounterparties` API 方法,请求后端 `/ccdi/project/bank-statement/counterparty/list`。
|
||||
3. 对手方模式下复用左侧筛选区和分页组件,隐藏“导出流水”按钮。
|
||||
4. 对手方表格展示对手方名称、流入合计、流出合计、总交易额和最近交易时间。
|
||||
5. 对手方模式默认按总交易额倒序;表格列排序映射到后端白名单字段。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 前端构建:
|
||||
`npm run build:prod`
|
||||
- 真实页面验证:
|
||||
1. 打开项目详情 `http://127.0.0.1:8081/ccdiProject/detail/42?tab=detail`。
|
||||
2. 进入“流水明细查询”,切换到“对手方”。
|
||||
3. 确认列表展示对手方名称、流入/流出/总交易额与分页总数。
|
||||
4. 确认对手方表格不展示“操作”列和“查看流水”按钮。
|
||||
|
||||
## 本机 Node 说明
|
||||
|
||||
`.nvmrc` 指向 `14.21.3`,但本机该版本环境不可稳定提供 npm。按仓库约定,本次使用已验证可用的 Node `22.22.3` 与 npm `10.9.8` 完成构建和页面验证。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 业务外部接口日志前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
在“系统管理 > 日志管理”下新增“接口日志”页面,以紧凑列表和独立详情展示业务外部接口调用情况及完整请求响应。
|
||||
|
||||
## 实施内容
|
||||
|
||||
1. 新增`src/api/monitor/apilog.js`,封装分页列表和详情接口。
|
||||
2. 新增`src/views/monitor/apilog/index.vue`,支持接口地址、HTTP方法、HTTP调用状态和调用时间筛选。
|
||||
3. 列表展示日志编号、调用账号、接口地址、HTTP方法、HTTP状态、HTTP调用状态、耗时和调用时间。
|
||||
4. 详情按需二次加载,展示调用账号和用户ID、URL、Content-Type、请求头、请求参数、响应头、原始返回正文和异常。
|
||||
5. 请求响应长文本使用等宽字体和固定最大高度;可解析的JSON基于原始字符串进行无损结构美化并按2空格缩进展示,不将数值反序列化后再输出,普通文本保留原始换行,长字段按容器自动折行;使用文本插值,禁止`v-html`。
|
||||
6. 页面只提供查询和详情,不增加删除、清空或导出操作。
|
||||
7. 详情弹窗使用响应式宽度和桌面最大宽度,长URL和长文本不得撑破页面。
|
||||
|
||||
## 验证标准
|
||||
|
||||
- 使用项目`.nvmrc`切换Node版本后,生产构建通过。
|
||||
- 管理员可从真实系统菜单进入接口日志页,列表、筛选、分页和详情正常。
|
||||
- 成功、失败、空响应和超长响应均能稳定展示,JSON缩进、超大整数原值、中文、原始换行和长字段自动折行正确。
|
||||
- 外部响应中的HTML只作为文本显示,不产生脚本或页面节点。
|
||||
- 未授权用户看不到详情入口且直接请求详情接口被拒绝。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 未提交账户前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
在结果总览风险明细页面新增“未提交账户”展示区,形态参考 lsfx 截图:顶部汇总、主体分组、账号卡片和导出流水入口。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- `projectOverview.js` 增加未提交账户查询 API。
|
||||
- `RiskDetailSection.vue` 增加未提交账户区块。
|
||||
- 展示未提交账户数、流入总额、流出总额、流入/流出差额。
|
||||
- 按主体展示账号卡片,卡片显示账号、流入金额、流出金额和交易占比较高标记。
|
||||
- 导出按钮调用后端未提交账户流水导出接口。
|
||||
|
||||
## 交互规则
|
||||
|
||||
- 当前项目为空时显示空态。
|
||||
- 查询失败时展示错误提示并清空未提交账户数据。
|
||||
- 账号显示使用中间省略,金额按万元展示。
|
||||
- 交易占比较高按后端返回的标记展示。
|
||||
|
||||
## 验证
|
||||
|
||||
- 前端单元静态断言覆盖标题、汇总字段、导出入口和卡片字段。
|
||||
- 按项目要求使用可用 Node/npm 版本完成构建或测试。
|
||||
- 页面开发完成后使用浏览器实际验证。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 流水明细对手方聚合查询实施记录
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 后端新增 `GET /ccdi/project/bank-statement/counterparty/list`,按对方名称聚合流水,返回流入合计、流出合计、总交易额、笔数和最近交易时间。
|
||||
- 后端新增 `CcdiBankStatementCounterpartyVO`,并在 Mapper XML 中复用明细查询筛选条件和排序白名单。
|
||||
- 前端在项目详情“流水明细查询”中增加“流水明细 / 对手方”切换。
|
||||
- 前端新增对手方聚合表格,支持按聚合指标排序;按用户反馈移除“查看流水”入口,避免从汇总视图跳到逐笔流水后缺少明确返回路径。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 涉及 `ccdi-project` 的银行流水查询 Controller、Service、Mapper 与 XML。
|
||||
- 涉及 `ruoyi-ui` 的流水明细查询组件与银行流水 API 文件。
|
||||
- 不涉及数据库表结构、菜单、权限标识或初始化数据变更。
|
||||
|
||||
## 验证情况
|
||||
|
||||
- 后端单测通过:
|
||||
`mvn -pl ccdi-project -am "-Dtest=CcdiBankStatementMapperXmlTest,CcdiBankStatementControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`
|
||||
- 前端构建通过:
|
||||
`npm run build:prod`
|
||||
- 真实页面验证通过:
|
||||
- 打开真实项目详情页 `http://127.0.0.1:8081/ccdiProject/detail/42?tab=detail`。
|
||||
- 切换“对手方”后,接口 `/ccdi/project/bank-statement/counterparty/list` 返回 200,页面展示“美团、微信支付、银行转账”等聚合行和 `共 8 条`。
|
||||
- 对手方表格已移除“操作”列和“查看流水”按钮,只保留汇总信息展示与表格排序。
|
||||
- 浏览器验证说明:`browser-use` 对应的 Chrome DevTools MCP 被本机 profile 锁占用,本次按 Playwright 技能要求使用 `@playwright/cli` 在真实页面完成验证。
|
||||
|
||||
## 环境说明
|
||||
|
||||
- `.nvmrc` 为 `14.21.3`,但本机 nvm 的 Node 14 环境无法稳定提供 npm。
|
||||
- 本次前端构建与页面验证使用 Node `22.22.3`、npm `10.9.8`;部分 PowerShell 会话中 nvm 切换后 PATH 不刷新,因此构建使用已确认存在的 `C:\Users\20696\AppData\Local\nvm-nodejs\npm.cmd`。
|
||||
- 前端构建存在项目既有体积告警,包含 `login-background`、`chunk-elementUI`、`chunk-libs`、`0uS5znL.docx` 等资产超限;未发现由本次代码变更引入的构建失败。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 征信本地文件清理后端实施记录
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 为征信 HTML 存储服务增加本地文件删除能力,并限制删除范围为 `ruoyi.profile/credit-html`。
|
||||
- 征信上传调用外部解析接口后立即删除本地文件,再继续解析结果校验与入库。
|
||||
- 解析失败时同样清理文件;清理异常不覆盖原始解析错误。
|
||||
- 删除失败时当前文件不再入库,批量上传继续处理后续文件。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 后端模块:`ccdi-info-collection`
|
||||
- 前端、接口协议、数据库结构均无变更。
|
||||
- 外部 `remotePath` 测试接口无变更。
|
||||
|
||||
## 验证情况
|
||||
|
||||
### 定向测试
|
||||
|
||||
执行命令:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
结果:通过,共执行 9 个用例,失败 0、错误 0。已覆盖解析成功、解析失败、结果校验失败、入库失败、删除失败、批量继续处理、真实文件删除、重复删除和越界路径拒绝。
|
||||
|
||||
### 模块测试
|
||||
|
||||
执行命令:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-info-collection -am test
|
||||
```
|
||||
|
||||
结果:共执行 178 个用例,本次修改相关测试全部通过;模块整体存在 1 个失败和 1 个错误:
|
||||
|
||||
- `CcdiBaseStaffMapperTest.mapperXml_shouldUseStableOrderForBaseStaffPagination`:员工 Mapper 排序契约断言失败。
|
||||
- `CcdiBaseStaffServiceImplTest.selectBaseStaffById_shouldReturnSelfOwnedAssetInfoList`:员工详情测试返回空对象并触发空指针。
|
||||
|
||||
上述失败位于未修改的员工信息代码与测试中,与本次征信本地文件清理无关,本次未扩大范围处理。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 业务外部接口日志实施记录
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 新增`sys_api_log`表及“日志管理 > 接口日志”菜单增量SQL。
|
||||
- 新增接口日志持久化、列表查询、详情查询及对应权限控制。
|
||||
- 将流水分析和征信解析调用统一接入完整请求响应日志。
|
||||
- 新增显式调用用户快照,并贯穿项目创建、征信解析、批量上传和拉取本行信息异步链路。
|
||||
- 流水分析和征信解析测试控制器取消匿名访问,改为校验`ccdi:project:edit`并记录当前登录用户。
|
||||
- 新增接口日志前端列表、筛选和完整详情页面。
|
||||
- 接口日志详情支持JSON无损结构美化、2空格缩进展示和长文本自动换行;超大整数不经过JavaScript数值再序列化,非JSON内容保持原文。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `ruoyi-system`:接口日志实体、DTO/VO、Mapper和事务Service。
|
||||
- `ruoyi-admin`:接口日志监控接口。
|
||||
- `ccdi-lsfx`:调用用户上下文、HTTP原始响应处理与日志写入。
|
||||
- `ccdi-project`、`ccdi-info-collection`:调用用户显式透传。
|
||||
- `ruoyi-ui`:接口日志菜单页面和API封装。
|
||||
- `sql/migration`:表结构与菜单权限增量脚本。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- JDK 21主工程编译及`ruoyi-admin`全模块跳过测试打包:已通过。
|
||||
- `ccdi-lsfx`定向测试:20个测试全部通过,覆盖7类HTTP调用、成功原始正文、非2xx、网络异常、空响应、反序列化失败、请求快照失败、multipart文件元数据、日志入库失败、调用用户必填及`NULL/system`系统调用人。
|
||||
- `ccdi-project`定向测试:57个既有回归测试全部通过;新增异步调用人隔离用例后,`CcdiFileUploadServiceImplTest`共33个测试全部通过,验证清理`SecurityContext`后用户A、用户B连续任务不串号。
|
||||
- `ccdi-info-collection`征信调用人透传测试:5个测试全部通过。
|
||||
- 前端生产构建:通过`.nvmrc`切换Node 14.21.3后执行`npm run build:prod`成功,仅保留项目既有资源体积告警。
|
||||
- 数据库迁移:使用`bin/mysql_utf8_exec.sh`连续执行两次均成功;`sys_api_log`表排序规则为`utf8mb4_general_ci`,`caller_username`无默认值,`api_url`为`TEXT`,请求参数和响应正文为`LONGTEXT`;菜单与查询按钮均保持单条。
|
||||
- 真实接口日志:本地Mock下生成6条真实调用日志,覆盖Token、multipart上传、征信URL-encoded发起及结果轮询;日志调用人为`admin`、用户ID为1,multipart仅保存文件名、10423字节大小和媒体类型,详情保留完整原始响应。
|
||||
- 权限验证:未登录请求`/monitor/apilog/list`返回业务码401;管理员列表与详情接口返回正常。
|
||||
- Browser use真实页面验收:应用内浏览器从“系统管理 > 日志管理 > 接口日志”进入成功;列表显示调用账号,按`xfeature`筛选从6条收敛为2条,详情完整展示用户、URL、请求响应头、请求参数、原始正文、HTTP状态及耗时,长文本滚动区域无溢出。
|
||||
- 详情格式化验收:可解析JSON按层级换行缩进展示,`9223372036854775807`等超大整数保持原值,长字段在内容区域内自动折行;异常堆栈等非JSON文本保留原始换行。
|
||||
- 测试清理:Browser use验收完成后删除本轮生成的6条接口日志测试数据,并关闭后端、前端和Mock测试进程。
|
||||
|
||||
## 说明
|
||||
|
||||
- 认证字段按已确认需求原文保存,接口日志访问权限必须严格控制。
|
||||
- `call_status`表示HTTP传输与响应反序列化状态,页面统一标注为“HTTP调用状态”,不表示外部系统业务码结果。
|
||||
- multipart日志仅保存文件元数据,不保存文件字节。
|
||||
- 失败日志的异常字段保存完整异常堆栈,不只保存异常消息。
|
||||
- 日志入库异常不会改变外部接口调用结果。
|
||||
- 临时测试数据、临时测试文件和浏览器验收产物不纳入Git提交范围;既有回归测试因公共方法签名变化产生的适配随业务代码一并交付。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 未提交账户实施记录
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 新增结果总览“未提交账户”后端查询与导出能力。
|
||||
- 新增前端风险明细未提交账户卡片展示。
|
||||
- 新增未提交账户卡片点击后的本地“流水明细”弹窗,按 `LE_ACCOUNT_NAME + CUSTOMER_ACCOUNT_NO` 当前卡片范围查询,不跳转“流水明细查询”Tab。
|
||||
- 新增未提交账户流水查询接口 `GET /ccdi/project/overview/unsubmitted-accounts/flows`;原未提交账户导出接口支持可选 `subjectName/accountNo` 过滤,用于弹窗内导出当前账号流水。
|
||||
- 风险明细统一导出与结果总览报告接入未提交账户流水。
|
||||
- 贷款排除仅使用银行贷款/信贷高确定性关键词,未启用内部杂项账号排除。
|
||||
- 本地项目 `90342(test拉取行内流水)` 补充未提交账户演示数据,统一流水号标记为 `UNSUBMITTED_ACCOUNT_SEED_90342_20260720%`。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 后端:`ccdi-project` 结果总览 Controller、Service、Mapper、DTO、VO、Excel 与导出器。
|
||||
- 前端:`ruoyi-ui` 结果总览风险明细组件与 API,未提交账户卡片新增本地明细弹窗与当前账号导出。
|
||||
- 文档:后端计划、前端计划、实施记录。
|
||||
|
||||
## 验证情况
|
||||
|
||||
- 后端增量测试首次执行通过:`mvn -pl ccdi-project "-Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectRiskDetailWorkbookExporterTest,CcdiProjectOverviewServiceImplTest" test`,共 25 个测试通过。
|
||||
- 执行 `mvn -pl ruoyi-admin -am clean package -DskipTests`,后端全链路依赖模块打包成功。
|
||||
- Mapper XML well-formed 检查通过。
|
||||
- 前端使用 Node `v22.22.3`、npm `10.9.8` 执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js`,静态断言通过。
|
||||
- 前端使用 Node `v22.22.3`、npm `10.9.8` 启动 `npm run dev -- --host 127.0.0.1 --port 8088`,编译成功。
|
||||
- 本地补数验证:`output/sql/unsubmitted_account_seed_90342.sql` 插入 8 条流水,其中 4 个同名未提交账户聚合展示、1 个同名贷款账户通过“归还贷款/贷款还款”口径被排除;清理脚本为 `output/sql/unsubmitted_account_cleanup_90342.sql`。
|
||||
- 浏览器验证:已打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,页面显示“未提交账户”4 个、流入总额 100.90 万元、流出总额 -1.16 万元、差额 99.74 万元。
|
||||
|
||||
## 2026-07-20 追加:未提交账户流水弹窗
|
||||
|
||||
- 后端复用未提交账户候选流水 SQL 与贷款排除口径,增加按主体名称和未提交账号过滤的流水查询能力。
|
||||
- 前端未提交账户卡片支持点击/回车打开本地弹窗,弹窗标题展示日期范围、主体和账号,并提供全部/流入/流出切换。
|
||||
- 弹窗内“导出流水”只导出当前主体当前未提交账号流水;页面区块右上角“导出流水”仍导出当前项目全部未提交账户流水。
|
||||
- 后端验证:`mvn -pl ccdi-project -am "-Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectRiskDetailWorkbookExporterTest,CcdiProjectOverviewServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false" test` 通过,25 个测试通过。
|
||||
- 后端打包:先执行 `mvn -pl ruoyi-admin -am clean package -DskipTests` 时因旧后端进程锁定 `ruoyi-admin.jar` 导致 clean 失败;确认 62318 旧进程结束后执行 `mvn -pl ruoyi-admin -am package -DskipTests` 成功,并重启真实 Java 后端,PID 30896,端口 62318 已监听。
|
||||
- 前端验证:`.nvmrc` 指向 Node 14.21.3,但该环境缺少 npm;按项目约定使用 Node `v22.22.3`、npm `10.9.8` 执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过,并启动 `npm run dev -- --host 127.0.0.1 --port 8088` 编译成功。
|
||||
- 浏览器验证:打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,未提交账户显示 4 个;点击首个账号卡片后弹出本地“流水明细”弹窗,接口 `GET /dev-api/ccdi/project/overview/unsubmitted-accounts/flows?projectId=90342&subjectName=测试主体&accountNo=33001061559364` 返回 200,弹窗显示全部 2、流入 1、流出 1,控制台无错误。
|
||||
|
||||
## 2026-07-20 追加:多主体展示优化
|
||||
|
||||
- 前端未提交账户统计栏新增“涉及主体数”,从 `subjects.length` 计算,不改后端接口与 SQL 口径。
|
||||
- 主体分组标题继续只展示主体名称,不增加“主体:”前缀;多主体时按主体名称分组列出各自账号卡片。
|
||||
- 未提交账户区块右侧导出按钮调整为与本页其他明细导出一致的文字按钮“导出”;弹窗内当前账号导出仍保留“导出流水”。
|
||||
- 前端验证:Node `v22.22.3`、npm `10.9.8` 下执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过。
|
||||
- 浏览器验证:打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,未提交账户区块显示“涉及主体数 1”、主体分组标题显示“测试主体”,区块右侧按钮显示“导出”,控制台无错误。
|
||||
|
||||
## 2026-07-20 追加:涉疑交易导出位置调整
|
||||
|
||||
- 移除“风险明细”区块标题右侧的综合导出按钮。
|
||||
- 将导出按钮移动到“涉疑交易明细”查询条件行右侧。
|
||||
- 导出接口改为 `POST /ccdi/project/overview/suspicious-transactions/export`,只导出涉疑交易明细单独 Excel,不再包含负面征信、异常账户和未提交账户等综合风险明细内容。
|
||||
- “交易占比较高”仍使用后端 `highRatio` 字段,判定逻辑保持为未提交账号流入或流出占对应主体总流入/总流出达到 10%。
|
||||
- 前端验证:Node `v22.22.3`、npm `10.9.8` 下执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过。
|
||||
- 浏览器验证:打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,确认“风险明细”标题右侧无导出按钮,“涉疑交易明细”筛选行右侧显示“导出”;点击后请求 `POST /dev-api/ccdi/project/overview/suspicious-transactions/export` 返回 200,控制台无错误。
|
||||
|
||||
## 2026-07-20 追加:文案与涉疑交易导出列顺序调整
|
||||
|
||||
- 未提交账户说明文案调整为“被调查主体未提交分析的同名账户”,不再在页面说明中展示“已排除银行贷款类账号”。
|
||||
- 涉疑交易明细单独 Excel 导出列顺序调整为“交易金额”在“异常标签”左侧。
|
||||
- 综合风险明细工作簿中的“涉疑交易明细”Sheet 同步调整列顺序,保持同类导出字段顺序一致。
|
||||
- 后端验证:执行 `mvn -pl ccdi-project -am "-Dtest=CcdiProjectRiskDetailWorkbookExporterTest" "-Dsurefire.failIfNoSpecifiedTests=false" test` 通过,覆盖综合风险明细工作簿列顺序与涉疑交易单独 Excel 字段顺序。
|
||||
- 前端验证:Node `v22.22.3`、npm `10.9.8` 下执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过。
|
||||
- 浏览器验证:刷新 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,页面显示新说明文案,未出现“已排除银行贷款类账号”;涉疑交易表格列顺序为“交易金额”在“异常标签”左侧,控制台无错误。
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ruoyi.web.controller.monitor;
|
||||
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.system.domain.dto.SysApiLogQueryDTO;
|
||||
import com.ruoyi.system.domain.vo.SysApiLogListVO;
|
||||
import com.ruoyi.system.service.ISysApiLogService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 业务外部接口日志。
|
||||
*/
|
||||
@Tag(name = "接口日志")
|
||||
@RestController
|
||||
@RequestMapping("/monitor/apilog")
|
||||
public class SysApiLogController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ISysApiLogService apiLogService;
|
||||
|
||||
@Operation(summary = "查询接口日志列表")
|
||||
@PreAuthorize("@ss.hasPermi('monitor:apilog:list')")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(SysApiLogQueryDTO queryDTO) {
|
||||
startPage();
|
||||
List<SysApiLogListVO> list = apiLogService.selectApiLogList(queryDTO);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询接口日志详情")
|
||||
@PreAuthorize("@ss.hasPermi('monitor:apilog:query')")
|
||||
@GetMapping("/{logId}")
|
||||
public AjaxResult detail(@PathVariable Long logId) {
|
||||
return success(apiLogService.selectApiLogById(logId));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.system.domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 业务外部接口调用日志。
|
||||
*/
|
||||
@Data
|
||||
public class SysApiLog {
|
||||
|
||||
private Long logId;
|
||||
private Long callerUserId;
|
||||
private String callerUsername;
|
||||
private String apiUrl;
|
||||
private String httpMethod;
|
||||
private String contentType;
|
||||
private String requestHeaders;
|
||||
private String requestParams;
|
||||
private Integer responseStatus;
|
||||
private String responseHeaders;
|
||||
private String responseBody;
|
||||
private String callStatus;
|
||||
private String errorMsg;
|
||||
private Long costTime;
|
||||
private Date callTime;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ruoyi.system.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 接口日志查询条件。
|
||||
*/
|
||||
@Data
|
||||
public class SysApiLogQueryDTO {
|
||||
|
||||
private String apiUrl;
|
||||
private String httpMethod;
|
||||
private String callStatus;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date beginTime;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date endTime;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ruoyi.system.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 接口日志完整详情。
|
||||
*/
|
||||
@Data
|
||||
public class SysApiLogDetailVO {
|
||||
|
||||
private Long logId;
|
||||
private Long callerUserId;
|
||||
private String callerUsername;
|
||||
private String apiUrl;
|
||||
private String httpMethod;
|
||||
private String contentType;
|
||||
private String requestHeaders;
|
||||
private String requestParams;
|
||||
private Integer responseStatus;
|
||||
private String responseHeaders;
|
||||
private String responseBody;
|
||||
private String callStatus;
|
||||
private String errorMsg;
|
||||
private Long costTime;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date callTime;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.system.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 接口日志列表项,不包含请求响应大字段。
|
||||
*/
|
||||
@Data
|
||||
public class SysApiLogListVO {
|
||||
|
||||
private Long logId;
|
||||
private Long callerUserId;
|
||||
private String callerUsername;
|
||||
private String apiUrl;
|
||||
private String httpMethod;
|
||||
private Integer responseStatus;
|
||||
private String callStatus;
|
||||
private Long costTime;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date callTime;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.system.mapper;
|
||||
|
||||
import com.ruoyi.system.domain.SysApiLog;
|
||||
import com.ruoyi.system.domain.dto.SysApiLogQueryDTO;
|
||||
import com.ruoyi.system.domain.vo.SysApiLogDetailVO;
|
||||
import com.ruoyi.system.domain.vo.SysApiLogListVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 接口日志数据层。
|
||||
*/
|
||||
public interface SysApiLogMapper {
|
||||
|
||||
int insertApiLog(SysApiLog apiLog);
|
||||
|
||||
List<SysApiLogListVO> selectApiLogList(SysApiLogQueryDTO queryDTO);
|
||||
|
||||
SysApiLogDetailVO selectApiLogById(Long logId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.system.service;
|
||||
|
||||
import com.ruoyi.system.domain.SysApiLog;
|
||||
import com.ruoyi.system.domain.dto.SysApiLogQueryDTO;
|
||||
import com.ruoyi.system.domain.vo.SysApiLogDetailVO;
|
||||
import com.ruoyi.system.domain.vo.SysApiLogListVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 接口日志服务。
|
||||
*/
|
||||
public interface ISysApiLogService {
|
||||
|
||||
void recordApiLog(SysApiLog apiLog);
|
||||
|
||||
List<SysApiLogListVO> selectApiLogList(SysApiLogQueryDTO queryDTO);
|
||||
|
||||
SysApiLogDetailVO selectApiLogById(Long logId);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.system.service.impl;
|
||||
|
||||
import com.ruoyi.system.domain.SysApiLog;
|
||||
import com.ruoyi.system.domain.dto.SysApiLogQueryDTO;
|
||||
import com.ruoyi.system.domain.vo.SysApiLogDetailVO;
|
||||
import com.ruoyi.system.domain.vo.SysApiLogListVO;
|
||||
import com.ruoyi.system.mapper.SysApiLogMapper;
|
||||
import com.ruoyi.system.service.ISysApiLogService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 接口日志服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class SysApiLogServiceImpl implements ISysApiLogService {
|
||||
|
||||
@Resource
|
||||
private SysApiLogMapper apiLogMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void recordApiLog(SysApiLog apiLog) {
|
||||
apiLogMapper.insertApiLog(apiLog);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SysApiLogListVO> selectApiLogList(SysApiLogQueryDTO queryDTO) {
|
||||
return apiLogMapper.selectApiLogList(queryDTO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysApiLogDetailVO selectApiLogById(Long logId) {
|
||||
return apiLogMapper.selectApiLogById(logId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.system.mapper.SysApiLogMapper">
|
||||
|
||||
<insert id="insertApiLog" parameterType="com.ruoyi.system.domain.SysApiLog" useGeneratedKeys="true" keyProperty="logId">
|
||||
insert into sys_api_log (
|
||||
caller_user_id, caller_username, api_url, http_method, content_type,
|
||||
request_headers, request_params, response_status, response_headers,
|
||||
response_body, call_status, error_msg, cost_time, call_time
|
||||
) values (
|
||||
#{callerUserId}, #{callerUsername}, #{apiUrl}, #{httpMethod}, #{contentType},
|
||||
#{requestHeaders}, #{requestParams}, #{responseStatus}, #{responseHeaders},
|
||||
#{responseBody}, #{callStatus}, #{errorMsg}, #{costTime}, #{callTime}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<select id="selectApiLogList" parameterType="com.ruoyi.system.domain.dto.SysApiLogQueryDTO"
|
||||
resultType="com.ruoyi.system.domain.vo.SysApiLogListVO">
|
||||
select log_id as logId,
|
||||
caller_user_id as callerUserId,
|
||||
caller_username as callerUsername,
|
||||
api_url as apiUrl,
|
||||
http_method as httpMethod,
|
||||
response_status as responseStatus,
|
||||
call_status as callStatus,
|
||||
cost_time as costTime,
|
||||
call_time as callTime
|
||||
from sys_api_log
|
||||
<where>
|
||||
<if test="apiUrl != null and apiUrl != ''">
|
||||
and api_url like concat('%', #{apiUrl}, '%')
|
||||
</if>
|
||||
<if test="httpMethod != null and httpMethod != ''">
|
||||
and http_method = #{httpMethod}
|
||||
</if>
|
||||
<if test="callStatus != null and callStatus != ''">
|
||||
and call_status = #{callStatus}
|
||||
</if>
|
||||
<if test="beginTime != null">
|
||||
and call_time >= #{beginTime}
|
||||
</if>
|
||||
<if test="endTime != null">
|
||||
and call_time <= #{endTime}
|
||||
</if>
|
||||
</where>
|
||||
order by call_time desc, log_id desc
|
||||
</select>
|
||||
|
||||
<select id="selectApiLogById" parameterType="Long"
|
||||
resultType="com.ruoyi.system.domain.vo.SysApiLogDetailVO">
|
||||
select log_id as logId,
|
||||
caller_user_id as callerUserId,
|
||||
caller_username as callerUsername,
|
||||
api_url as apiUrl,
|
||||
http_method as httpMethod,
|
||||
content_type as contentType,
|
||||
request_headers as requestHeaders,
|
||||
request_params as requestParams,
|
||||
response_status as responseStatus,
|
||||
response_headers as responseHeaders,
|
||||
response_body as responseBody,
|
||||
call_status as callStatus,
|
||||
error_msg as errorMsg,
|
||||
cost_time as costTime,
|
||||
call_time as callTime
|
||||
from sys_api_log
|
||||
where log_id = #{logId}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -144,6 +144,26 @@ export function getOverviewAbnormalAccountPeople(params) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getOverviewUnsubmittedAccounts(projectId) {
|
||||
return request({
|
||||
url: '/ccdi/project/overview/unsubmitted-accounts',
|
||||
method: 'get',
|
||||
params: { projectId }
|
||||
})
|
||||
}
|
||||
|
||||
export function getOverviewUnsubmittedAccountFlows(params) {
|
||||
return request({
|
||||
url: '/ccdi/project/overview/unsubmitted-accounts/flows',
|
||||
method: 'get',
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
subjectName: params.subjectName,
|
||||
accountNo: params.accountNo
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function exportOverviewReport(projectId) {
|
||||
return request({
|
||||
url: '/ccdi/project/overview/report/export',
|
||||
|
||||
@@ -8,6 +8,14 @@ export function listBankStatement(query) {
|
||||
});
|
||||
}
|
||||
|
||||
export function listBankStatementCounterparties(query) {
|
||||
return request({
|
||||
url: "/ccdi/project/bank-statement/counterparty/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBankStatementOptions(projectId) {
|
||||
return request({
|
||||
url: "/ccdi/project/bank-statement/options",
|
||||
|
||||
18
ruoyi-ui/src/api/monitor/apilog.js
Normal file
18
ruoyi-ui/src/api/monitor/apilog.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询业务外部接口日志列表
|
||||
export function listApiLog(query) {
|
||||
return request({
|
||||
url: '/monitor/apilog/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询业务外部接口日志详情
|
||||
export function getApiLog(logId) {
|
||||
return request({
|
||||
url: '/monitor/apilog/' + logId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -152,8 +152,18 @@
|
||||
<div class="shell-header">
|
||||
<div class="shell-title-group">
|
||||
<span class="shell-title">流水明细查询</span>
|
||||
<el-radio-group
|
||||
v-model="resultMode"
|
||||
class="result-mode-switch"
|
||||
size="small"
|
||||
@change="handleResultModeChange"
|
||||
>
|
||||
<el-radio-button label="statement">流水明细</el-radio-button>
|
||||
<el-radio-button label="counterparty">对手方</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="resultMode === 'statement'"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@@ -164,7 +174,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="prefillTip"
|
||||
v-if="resultMode === 'statement' && prefillTip"
|
||||
:closable="false"
|
||||
class="prefill-alert"
|
||||
show-icon
|
||||
@@ -180,15 +190,24 @@
|
||||
|
||||
<div class="result-card">
|
||||
<el-alert
|
||||
v-if="listError"
|
||||
v-if="resultMode === 'statement' && listError"
|
||||
:closable="false"
|
||||
class="result-alert"
|
||||
show-icon
|
||||
title="流水明细加载失败,请稍后重试"
|
||||
type="error"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="resultMode === 'counterparty' && counterpartyError"
|
||||
:closable="false"
|
||||
class="result-alert"
|
||||
show-icon
|
||||
title="对手方加载失败,请稍后重试"
|
||||
type="error"
|
||||
/>
|
||||
|
||||
<el-table
|
||||
v-if="resultMode === 'statement'"
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
class="result-table"
|
||||
@@ -275,9 +294,82 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
v-loading="loading"
|
||||
:data="counterpartyList"
|
||||
class="result-table counterparty-table"
|
||||
@sort-change="handleCounterpartySortChange"
|
||||
>
|
||||
<template slot="empty">
|
||||
<el-empty
|
||||
:image-size="96"
|
||||
description="当前筛选条件下暂无对手方"
|
||||
/>
|
||||
</template>
|
||||
<el-table-column
|
||||
label="对手方名称"
|
||||
prop="customerAccountName"
|
||||
min-width="220"
|
||||
:formatter="formatCounterpartyNameColumn"
|
||||
/>
|
||||
<el-table-column
|
||||
label="流入合计"
|
||||
prop="inAmount"
|
||||
width="150"
|
||||
align="right"
|
||||
sortable="custom"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="amount-stack">
|
||||
<span class="amount-text amount-in">{{ formatAmount(scope.row.inAmount) }}</span>
|
||||
<span class="secondary-text">{{ formatCount(scope.row.inCount) }} 笔</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="流出合计"
|
||||
prop="outAmount"
|
||||
width="150"
|
||||
align="right"
|
||||
sortable="custom"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="amount-stack">
|
||||
<span class="amount-text amount-out">{{ formatAmount(scope.row.outAmount) }}</span>
|
||||
<span class="secondary-text">{{ formatCount(scope.row.outCount) }} 笔</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="总交易额"
|
||||
prop="totalAmount"
|
||||
width="150"
|
||||
align="right"
|
||||
sortable="custom"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="amount-stack">
|
||||
<span class="amount-text">{{ formatAmount(scope.row.totalAmount) }}</span>
|
||||
<span class="secondary-text">{{ formatCount(scope.row.totalCount) }} 笔</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="最近交易时间"
|
||||
prop="latestTrxDate"
|
||||
min-width="170"
|
||||
sortable="custom"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
{{ formatField(scope.row.latestTrxDate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-show="currentTotal > 0"
|
||||
:total="currentTotal"
|
||||
:page.sync="queryParams.pageNum"
|
||||
:limit.sync="queryParams.pageSize"
|
||||
@pagination="handlePageChange"
|
||||
@@ -411,6 +503,7 @@
|
||||
import { parseTime } from "@/utils/ruoyi";
|
||||
import {
|
||||
listBankStatement,
|
||||
listBankStatementCounterparties,
|
||||
getBankStatementOptions,
|
||||
getBankStatementDetail,
|
||||
} from "@/api/ccdiProjectBankStatement";
|
||||
@@ -427,6 +520,14 @@ const SORT_MAP = {
|
||||
displayAmount: "amount",
|
||||
};
|
||||
|
||||
const COUNTERPARTY_SORT_MAP = {
|
||||
inAmount: "inAmount",
|
||||
outAmount: "outAmount",
|
||||
totalAmount: "totalAmount",
|
||||
totalCount: "totalCount",
|
||||
latestTrxDate: "latestTrxDate",
|
||||
};
|
||||
|
||||
const createDefaultQueryParams = (projectId) => ({
|
||||
projectId,
|
||||
pageNum: 1,
|
||||
@@ -517,10 +618,14 @@ export default {
|
||||
optionsLoading: false,
|
||||
detailLoading: false,
|
||||
detailVisible: false,
|
||||
resultMode: "statement",
|
||||
activeTab: "all",
|
||||
dateRange: [],
|
||||
list: [],
|
||||
total: 0,
|
||||
counterpartyList: [],
|
||||
counterpartyTotal: 0,
|
||||
counterpartyError: "",
|
||||
listError: "",
|
||||
prefillTip: "",
|
||||
detailData: createEmptyDetailData(),
|
||||
@@ -532,6 +637,11 @@ export default {
|
||||
this.getList();
|
||||
this.getOptions();
|
||||
},
|
||||
computed: {
|
||||
currentTotal() {
|
||||
return this.resultMode === "counterparty" ? this.counterpartyTotal : this.total;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dateRange(value) {
|
||||
this.queryParams.transactionStartTime = value && value[0] ? value[0] : "";
|
||||
@@ -557,12 +667,19 @@ export default {
|
||||
if (!this.queryParams.projectId) {
|
||||
this.list = [];
|
||||
this.total = 0;
|
||||
this.counterpartyList = [];
|
||||
this.counterpartyTotal = 0;
|
||||
this.listError = "";
|
||||
this.counterpartyError = "";
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
if (this.resultMode === "counterparty") {
|
||||
await this.getCounterpartyList();
|
||||
return;
|
||||
}
|
||||
const res = await listBankStatement(this.queryParams);
|
||||
this.list = (res.rows || []).map((item) => ({
|
||||
...item,
|
||||
@@ -579,6 +696,19 @@ export default {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async getCounterpartyList() {
|
||||
try {
|
||||
const res = await listBankStatementCounterparties(this.queryParams);
|
||||
this.counterpartyList = res.rows || [];
|
||||
this.counterpartyTotal = res.total || 0;
|
||||
this.counterpartyError = "";
|
||||
} catch (error) {
|
||||
this.counterpartyList = [];
|
||||
this.counterpartyTotal = 0;
|
||||
this.counterpartyError = "加载对手方失败";
|
||||
console.error("加载对手方失败", error);
|
||||
}
|
||||
},
|
||||
async getOptions() {
|
||||
this.syncProjectId();
|
||||
if (!this.queryParams.projectId) {
|
||||
@@ -624,6 +754,7 @@ export default {
|
||||
if (!value || !Array.isArray(value.ourCertNos) || !value.ourCertNos.length) {
|
||||
return;
|
||||
}
|
||||
this.resultMode = "statement";
|
||||
this.activeTab = "all";
|
||||
this.dateRange = [];
|
||||
this.queryParams = {
|
||||
@@ -634,6 +765,17 @@ export default {
|
||||
this.syncProjectId();
|
||||
this.getList();
|
||||
},
|
||||
handleResultModeChange(mode) {
|
||||
this.queryParams.pageNum = 1;
|
||||
if (mode === "counterparty") {
|
||||
this.queryParams.orderBy = "totalAmount";
|
||||
this.queryParams.orderDirection = "desc";
|
||||
} else {
|
||||
this.queryParams.orderBy = "trxDate";
|
||||
this.queryParams.orderDirection = "desc";
|
||||
}
|
||||
this.getList();
|
||||
},
|
||||
handleTabChange(tab) {
|
||||
const tabName = tab && tab.name ? tab.name : this.activeTab;
|
||||
this.activeTab = tabName;
|
||||
@@ -650,6 +792,15 @@ export default {
|
||||
}
|
||||
this.getList();
|
||||
},
|
||||
handleCounterpartySortChange({ prop, order }) {
|
||||
this.queryParams.orderBy = COUNTERPARTY_SORT_MAP[prop] || "totalAmount";
|
||||
this.queryParams.orderDirection = order === "ascending" ? "asc" : "desc";
|
||||
if (!order) {
|
||||
this.queryParams.orderBy = "totalAmount";
|
||||
this.queryParams.orderDirection = "desc";
|
||||
}
|
||||
this.getList();
|
||||
},
|
||||
handlePageChange(pageInfo) {
|
||||
if (typeof pageInfo === "number") {
|
||||
this.queryParams.pageNum = pageInfo;
|
||||
@@ -743,6 +894,13 @@ export default {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
},
|
||||
formatCount(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return 0;
|
||||
}
|
||||
const count = Number(value);
|
||||
return Number.isNaN(count) ? 0 : count;
|
||||
},
|
||||
formatSignedAmount(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
@@ -766,6 +924,15 @@ export default {
|
||||
}
|
||||
return this.formatField(detail.customerAccountName);
|
||||
},
|
||||
formatCounterpartyRowName(row) {
|
||||
if (!row || row.nameEmpty || !row.customerAccountName) {
|
||||
return "为空";
|
||||
}
|
||||
return this.formatField(row.customerAccountName);
|
||||
},
|
||||
formatCounterpartyNameColumn(row) {
|
||||
return this.formatCounterpartyRowName(row);
|
||||
},
|
||||
formatOriginalFileName(detail) {
|
||||
if (!detail) {
|
||||
return "暂无原始文件";
|
||||
@@ -876,6 +1043,14 @@ export default {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.result-mode-switch {
|
||||
margin-top: 8px;
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
min-width: 82px;
|
||||
}
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -1011,6 +1186,15 @@ export default {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-stack {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.amount-in {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,100 @@
|
||||
<div class="section-title">风险明细</div>
|
||||
<div class="section-subtitle">展示涉疑交易与异常账户关联人员信息</div>
|
||||
</div>
|
||||
<el-button size="mini" type="text" :disabled="!projectId" @click="handleRiskDetailExport">
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div class="block-header">
|
||||
<div>
|
||||
<div class="block-title">未提交账户</div>
|
||||
<div class="block-subtitle">被调查主体未提交分析的同名账户</div>
|
||||
</div>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
:disabled="!projectId || unsubmittedAccountTotal <= 0"
|
||||
@click="handleUnsubmittedAccountExport"
|
||||
>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="unsubmittedAccountLoading" class="unsubmitted-panel">
|
||||
<template v-if="unsubmittedAccountTotal > 0">
|
||||
<div class="unsubmitted-metrics">
|
||||
<div class="unsubmitted-metric">
|
||||
<div class="metric-label">涉及主体数</div>
|
||||
<div class="metric-value">{{ unsubmittedSubjectTotal }}</div>
|
||||
</div>
|
||||
<div class="unsubmitted-metric">
|
||||
<div class="metric-label">未提交账户数</div>
|
||||
<div class="metric-value">{{ unsubmittedAccountTotal }}</div>
|
||||
</div>
|
||||
<div class="unsubmitted-metric">
|
||||
<div class="metric-label">流入总额</div>
|
||||
<div class="metric-value amount-in">{{ formatWanAmount(unsubmittedAccountSummary.totalAmountCr) }}</div>
|
||||
</div>
|
||||
<div class="unsubmitted-metric">
|
||||
<div class="metric-label">流出总额</div>
|
||||
<div class="metric-value amount-out">-{{ formatWanAmount(unsubmittedAccountSummary.totalAmountDr) }}</div>
|
||||
</div>
|
||||
<div class="unsubmitted-metric">
|
||||
<div class="metric-label">流入/流出差额</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
:class="Number(unsubmittedAccountSummary.netAmount || 0) >= 0 ? 'amount-in' : 'amount-out'"
|
||||
>
|
||||
{{ formatWanAmount(unsubmittedAccountSummary.netAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="subject in unsubmittedAccountSubjects"
|
||||
:key="subject.subjectName"
|
||||
class="unsubmitted-subject"
|
||||
>
|
||||
<div class="unsubmitted-subject-name">{{ formatField(subject.subjectName) }}</div>
|
||||
<div class="unsubmitted-account-grid">
|
||||
<div
|
||||
v-for="account in subject.accounts"
|
||||
:key="`${subject.subjectName}-${account.accountNo}`"
|
||||
class="unsubmitted-account-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleUnsubmittedAccountCardClick(subject, account)"
|
||||
@keyup.enter="handleUnsubmittedAccountCardClick(subject, account)"
|
||||
>
|
||||
<div class="unsubmitted-account-head">
|
||||
<div class="unsubmitted-account-no">
|
||||
<i class="el-icon-bank-card"></i>
|
||||
<span>{{ maskAccountNo(account.accountNo) }}</span>
|
||||
</div>
|
||||
<el-tag
|
||||
v-if="account.highRatio"
|
||||
size="mini"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>
|
||||
交易占比较高
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="unsubmitted-card-metrics">
|
||||
<div>
|
||||
<div class="card-metric-label">流入金额</div>
|
||||
<div class="card-metric-value amount-in">{{ formatWanAmount(account.totalAmountCr) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card-metric-label">流出金额</div>
|
||||
<div class="card-metric-value amount-out">-{{ formatWanAmount(account.totalAmountDr) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else :image-size="96" description="当前项目暂无未提交账户" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
@@ -56,6 +147,11 @@
|
||||
查询
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item class="suspicious-export-action">
|
||||
<el-button size="mini" type="text" :disabled="!projectId" @click="handleSuspiciousTransactionExport">
|
||||
导出
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
@@ -242,6 +338,96 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
:visible.sync="unsubmittedFlowVisible"
|
||||
append-to-body
|
||||
custom-class="unsubmitted-flow-dialog"
|
||||
width="88%"
|
||||
top="4vh"
|
||||
@close="closeUnsubmittedFlowDialog"
|
||||
>
|
||||
<template slot="title">
|
||||
<div class="unsubmitted-flow-title">
|
||||
<span>流水明细</span>
|
||||
<div v-if="selectedUnsubmittedAccount" class="unsubmitted-flow-title-tags">
|
||||
<span>{{ formatUnsubmittedFlowPeriod }}</span>
|
||||
<span>{{ formatField(selectedUnsubmittedAccount.subjectName) }}</span>
|
||||
<span>{{ formatField(selectedUnsubmittedAccount.accountNo) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="unsubmitted-flow-toolbar">
|
||||
<el-radio-group v-model="unsubmittedFlowType" size="small">
|
||||
<el-radio-button label="ALL">全部 · {{ unsubmittedFlowAllCount }}</el-radio-button>
|
||||
<el-radio-button label="INFLOW">流入 · {{ unsubmittedFlowInCount }}</el-radio-button>
|
||||
<el-radio-button label="OUTFLOW">流出 · {{ unsubmittedFlowOutCount }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button
|
||||
size="small"
|
||||
type="text"
|
||||
icon="el-icon-download"
|
||||
:disabled="!selectedUnsubmittedAccount || unsubmittedFlowAllCount <= 0"
|
||||
@click="handleUnsubmittedFlowExport"
|
||||
>
|
||||
导出流水
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="unsubmittedFlowLoading"
|
||||
:data="unsubmittedFlowFilteredList"
|
||||
class="result-table unsubmitted-flow-table"
|
||||
height="640"
|
||||
>
|
||||
<template slot="empty">
|
||||
<el-empty :image-size="96" description="当前账号暂无流水明细" />
|
||||
</template>
|
||||
<el-table-column label="交易时间" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
<div class="multi-line-cell">
|
||||
<div class="primary-text">{{ formatDatePart(scope.row.trxDate) }}</div>
|
||||
<div class="secondary-text">{{ formatTimePart(scope.row.trxDate) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本方账户/主体" min-width="240">
|
||||
<template slot-scope="scope">
|
||||
<div class="multi-line-cell">
|
||||
<div class="primary-text">{{ formatField(scope.row.leAccountNo) }}</div>
|
||||
<div class="secondary-text">{{ formatField(scope.row.leAccountName) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对方名称/账户" min-width="260">
|
||||
<template slot-scope="scope">
|
||||
<div class="multi-line-cell">
|
||||
<div class="primary-text link-text">{{ formatField(scope.row.customerAccountName) }}</div>
|
||||
<div class="secondary-text">{{ formatField(scope.row.customerAccountNo) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="摘要/交易类型" min-width="260" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<div class="multi-line-cell">
|
||||
<div class="primary-text">{{ formatField(scope.row.userMemo) }}</div>
|
||||
<div class="secondary-text">{{ formatField(scope.row.cashType) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交易金额" min-width="170" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span class="flow-amount" :class="getUnsubmittedFlowAmountClass(scope.row)">
|
||||
{{ formatUnsubmittedFlowAmount(scope.row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" min-width="130">
|
||||
<template>
|
||||
<span>未提交账户</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
:visible.sync="detailVisible"
|
||||
append-to-body
|
||||
@@ -369,6 +555,8 @@ import {
|
||||
getOverviewAbnormalAccountPeople,
|
||||
getOverviewEmployeeCreditNegative,
|
||||
getOverviewSuspiciousTransactions,
|
||||
getOverviewUnsubmittedAccountFlows,
|
||||
getOverviewUnsubmittedAccounts,
|
||||
} from "@/api/ccdi/projectOverview";
|
||||
import { getBankStatementDetail } from "@/api/ccdiProjectBankStatement";
|
||||
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
|
||||
@@ -559,6 +747,19 @@ export default {
|
||||
abnormalAccountPageSize: DEFAULT_RISK_DETAIL_PAGE_SIZE,
|
||||
abnormalAccountTotal: 0,
|
||||
abnormalAccountList: [],
|
||||
unsubmittedAccountLoading: false,
|
||||
unsubmittedAccountSummary: {
|
||||
totalAmountCr: 0,
|
||||
totalAmountDr: 0,
|
||||
netAmount: 0,
|
||||
},
|
||||
unsubmittedAccountTotal: 0,
|
||||
unsubmittedAccountSubjects: [],
|
||||
unsubmittedFlowVisible: false,
|
||||
unsubmittedFlowLoading: false,
|
||||
unsubmittedFlowType: "ALL",
|
||||
selectedUnsubmittedAccount: null,
|
||||
unsubmittedFlowList: [],
|
||||
projectId: null,
|
||||
statementDetailCache: {},
|
||||
suspiciousRequestSeq: 0,
|
||||
@@ -587,6 +788,33 @@ export default {
|
||||
allModelOptionLabel() {
|
||||
return this.isExternalSuspiciousType ? "全部外部模型" : "全部模型";
|
||||
},
|
||||
unsubmittedFlowFilteredList() {
|
||||
if (this.unsubmittedFlowType === "INFLOW") {
|
||||
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowIn(row));
|
||||
}
|
||||
if (this.unsubmittedFlowType === "OUTFLOW") {
|
||||
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowOut(row));
|
||||
}
|
||||
return this.unsubmittedFlowList;
|
||||
},
|
||||
unsubmittedFlowAllCount() {
|
||||
return this.unsubmittedFlowList.length;
|
||||
},
|
||||
unsubmittedSubjectTotal() {
|
||||
return this.unsubmittedAccountSubjects.length;
|
||||
},
|
||||
unsubmittedFlowInCount() {
|
||||
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowIn(row)).length;
|
||||
},
|
||||
unsubmittedFlowOutCount() {
|
||||
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowOut(row)).length;
|
||||
},
|
||||
formatUnsubmittedFlowPeriod() {
|
||||
if (!this.selectedUnsubmittedAccount) {
|
||||
return "-";
|
||||
}
|
||||
return `${this.formatDatePart(this.selectedUnsubmittedAccount.startDate)} ~ ${this.formatDatePart(this.selectedUnsubmittedAccount.endDate)}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
sectionData: {
|
||||
@@ -603,6 +831,8 @@ export default {
|
||||
this.abnormalAccountPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
|
||||
this.abnormalAccountTotal = 0;
|
||||
this.abnormalAccountList = [];
|
||||
this.unsubmittedAccountTotal = 0;
|
||||
this.unsubmittedAccountSubjects = [];
|
||||
this.employeeCreditNegativeList = Array.isArray(value && value.employeeCreditNegativeList)
|
||||
? value.employeeCreditNegativeList
|
||||
: [];
|
||||
@@ -620,8 +850,10 @@ export default {
|
||||
this.suspiciousLoading = false;
|
||||
this.statementDetailCache = {};
|
||||
this.closeDetailDialog();
|
||||
this.closeUnsubmittedFlowDialog();
|
||||
}
|
||||
this.loadAbnormalAccountPeople();
|
||||
this.loadUnsubmittedAccounts();
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -791,6 +1023,80 @@ export default {
|
||||
this.abnormalAccountLoading = false;
|
||||
}
|
||||
},
|
||||
async loadUnsubmittedAccounts() {
|
||||
if (!this.projectId) {
|
||||
this.unsubmittedAccountSummary = {
|
||||
totalAmountCr: 0,
|
||||
totalAmountDr: 0,
|
||||
netAmount: 0,
|
||||
};
|
||||
this.unsubmittedAccountTotal = 0;
|
||||
this.unsubmittedAccountSubjects = [];
|
||||
this.unsubmittedAccountLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubmittedAccountLoading = true;
|
||||
try {
|
||||
const response = await getOverviewUnsubmittedAccounts(this.projectId);
|
||||
const data = (response && response.data) || {};
|
||||
this.unsubmittedAccountSummary = {
|
||||
totalAmountCr: Number(data.totalAmountCr) || 0,
|
||||
totalAmountDr: Number(data.totalAmountDr) || 0,
|
||||
netAmount: Number(data.netAmount) || 0,
|
||||
};
|
||||
this.unsubmittedAccountTotal = Number(data.accountCount) || 0;
|
||||
this.unsubmittedAccountSubjects = Array.isArray(data.subjects) ? data.subjects : [];
|
||||
} catch (error) {
|
||||
this.unsubmittedAccountSummary = {
|
||||
totalAmountCr: 0,
|
||||
totalAmountDr: 0,
|
||||
netAmount: 0,
|
||||
};
|
||||
this.unsubmittedAccountTotal = 0;
|
||||
this.unsubmittedAccountSubjects = [];
|
||||
this.$message.error("加载未提交账户失败");
|
||||
console.error("加载未提交账户失败", error);
|
||||
} finally {
|
||||
this.unsubmittedAccountLoading = false;
|
||||
}
|
||||
},
|
||||
async handleUnsubmittedAccountCardClick(subject, account) {
|
||||
if (!this.projectId || !subject || !account || !account.accountNo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAccount = {
|
||||
...account,
|
||||
subjectName: subject.subjectName,
|
||||
};
|
||||
this.selectedUnsubmittedAccount = selectedAccount;
|
||||
this.unsubmittedFlowType = "ALL";
|
||||
this.unsubmittedFlowList = [];
|
||||
this.unsubmittedFlowVisible = true;
|
||||
this.unsubmittedFlowLoading = true;
|
||||
try {
|
||||
const response = await getOverviewUnsubmittedAccountFlows({
|
||||
projectId: this.projectId,
|
||||
subjectName: selectedAccount.subjectName,
|
||||
accountNo: selectedAccount.accountNo,
|
||||
});
|
||||
this.unsubmittedFlowList = Array.isArray(response && response.data) ? response.data : [];
|
||||
} catch (error) {
|
||||
this.unsubmittedFlowList = [];
|
||||
this.$message.error("加载未提交账户流水失败");
|
||||
console.error("加载未提交账户流水失败", error);
|
||||
} finally {
|
||||
this.unsubmittedFlowLoading = false;
|
||||
}
|
||||
},
|
||||
closeUnsubmittedFlowDialog() {
|
||||
this.unsubmittedFlowVisible = false;
|
||||
this.unsubmittedFlowLoading = false;
|
||||
this.unsubmittedFlowType = "ALL";
|
||||
this.selectedUnsubmittedAccount = null;
|
||||
this.unsubmittedFlowList = [];
|
||||
},
|
||||
async fetchStatementDetail(bankStatementId, silent, scope = {}) {
|
||||
if (!bankStatementId) {
|
||||
return createEmptyDetailData();
|
||||
@@ -885,18 +1191,42 @@ export default {
|
||||
const value = this.formatField(detail.leAccountName);
|
||||
return value === "-" ? "关联人员" : value;
|
||||
},
|
||||
handleRiskDetailExport() {
|
||||
handleSuspiciousTransactionExport() {
|
||||
if (!this.projectId) {
|
||||
return;
|
||||
}
|
||||
this.download(
|
||||
"ccdi/project/overview/risk-details/export",
|
||||
"ccdi/project/overview/suspicious-transactions/export",
|
||||
{
|
||||
projectId: this.projectId,
|
||||
modelCode: this.selectedModelCode,
|
||||
suspiciousType: this.currentSuspiciousType,
|
||||
},
|
||||
`风险明细_${this.projectId}_${new Date().getTime()}.xlsx`
|
||||
`涉疑交易明细_${this.projectId}_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
},
|
||||
handleUnsubmittedAccountExport() {
|
||||
if (!this.projectId || this.unsubmittedAccountTotal <= 0) {
|
||||
return;
|
||||
}
|
||||
this.download(
|
||||
"ccdi/project/overview/unsubmitted-accounts/export",
|
||||
{ projectId: this.projectId },
|
||||
`未提交账户流水_${this.projectId}_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
},
|
||||
handleUnsubmittedFlowExport() {
|
||||
if (!this.projectId || !this.selectedUnsubmittedAccount) {
|
||||
return;
|
||||
}
|
||||
this.download(
|
||||
"ccdi/project/overview/unsubmitted-accounts/export",
|
||||
{
|
||||
projectId: this.projectId,
|
||||
subjectName: this.selectedUnsubmittedAccount.subjectName,
|
||||
accountNo: this.selectedUnsubmittedAccount.accountNo,
|
||||
},
|
||||
`未提交账户流水_${this.projectId}_${this.selectedUnsubmittedAccount.accountNo}_${new Date().getTime()}.xlsx`
|
||||
);
|
||||
},
|
||||
formatRelatedStaff(row) {
|
||||
@@ -943,6 +1273,65 @@ export default {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
},
|
||||
formatWanAmount(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "0.00 万元";
|
||||
}
|
||||
const amount = Number(value);
|
||||
if (Number.isNaN(amount)) {
|
||||
return "0.00 万元";
|
||||
}
|
||||
return `${(amount / 10000).toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} 万元`;
|
||||
},
|
||||
maskAccountNo(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
const text = String(value);
|
||||
if (text.length <= 10) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, 8)} ... ${text.slice(-6)}`;
|
||||
},
|
||||
formatDatePart(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
return String(value).split(" ")[0] || "-";
|
||||
},
|
||||
formatTimePart(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
const parts = String(value).split(" ");
|
||||
return parts.length > 1 && parts[1] ? parts[1] : "-";
|
||||
},
|
||||
isUnsubmittedFlowIn(row) {
|
||||
return Number(row && row.amountCr) > 0;
|
||||
},
|
||||
isUnsubmittedFlowOut(row) {
|
||||
return Number(row && row.amountDr) > 0;
|
||||
},
|
||||
getUnsubmittedFlowAmountValue(row) {
|
||||
if (this.isUnsubmittedFlowIn(row)) {
|
||||
return Number(row.amountCr) || 0;
|
||||
}
|
||||
if (this.isUnsubmittedFlowOut(row)) {
|
||||
return -Number(row.amountDr || 0);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
formatUnsubmittedFlowAmount(row) {
|
||||
const amount = this.getUnsubmittedFlowAmountValue(row);
|
||||
const prefix = amount < 0 ? "-¥" : "¥";
|
||||
return `${prefix}${this.formatAmount(Math.abs(amount))}`;
|
||||
},
|
||||
getUnsubmittedFlowAmountClass(row) {
|
||||
return this.getUnsubmittedFlowAmountValue(row) >= 0 ? "amount-in" : "amount-out";
|
||||
},
|
||||
formatSignedAmount(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
@@ -1113,6 +1502,10 @@ export default {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.suspicious-export-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.suspicious-filter-select {
|
||||
width: 240px;
|
||||
}
|
||||
@@ -1121,6 +1514,176 @@ export default {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.unsubmitted-panel {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.unsubmitted-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.unsubmitted-metric {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 6px;
|
||||
color: #0f172a;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.unsubmitted-subject + .unsubmitted-subject {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.unsubmitted-subject-name {
|
||||
margin-bottom: 12px;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.unsubmitted-account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.unsubmitted-account-card {
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.unsubmitted-account-card:hover,
|
||||
.unsubmitted-account-card:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.1);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.unsubmitted-account-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.unsubmitted-account-no {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unsubmitted-account-no i {
|
||||
flex: 0 0 auto;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.unsubmitted-account-no span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.unsubmitted-card-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.card-metric-label {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.card-metric-value {
|
||||
margin-top: 4px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.unsubmitted-flow-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.unsubmitted-flow-title > span {
|
||||
flex: 0 0 auto;
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.unsubmitted-flow-title-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.unsubmitted-flow-title-tags span {
|
||||
max-width: 260px;
|
||||
padding: 4px 10px;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unsubmitted-flow-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.unsubmitted-flow-table :deep(.el-table__header th) {
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flow-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.result-table {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1355,6 +1918,14 @@ export default {
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.unsubmitted-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.unsubmitted-account-grid {
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.detail-overview-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px 24px;
|
||||
@@ -1362,6 +1933,12 @@ export default {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.unsubmitted-metrics,
|
||||
.unsubmitted-account-grid,
|
||||
.unsubmitted-card-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.suspicious-filter-form {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
289
ruoyi-ui/src/views/monitor/apilog/index.vue
Normal file
289
ruoyi-ui/src/views/monitor/apilog/index.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form
|
||||
ref="queryForm"
|
||||
:model="queryParams"
|
||||
size="small"
|
||||
:inline="true"
|
||||
v-show="showSearch"
|
||||
label-width="82px"
|
||||
>
|
||||
<el-form-item label="接口地址" prop="apiUrl">
|
||||
<el-input
|
||||
v-model="queryParams.apiUrl"
|
||||
placeholder="请输入接口地址"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="HTTP方法" prop="httpMethod">
|
||||
<el-select v-model="queryParams.httpMethod" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="GET" value="GET" />
|
||||
<el-option label="POST" value="POST" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="HTTP调用状态" prop="callStatus">
|
||||
<el-select v-model="queryParams.callStatus" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="成功" value="0" />
|
||||
<el-option label="失败" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="调用时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
range-separator="-"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
:default-time="['00:00:00', '23:59:59']"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="日志编号" align="center" prop="logId" width="100" />
|
||||
<el-table-column label="调用账号" align="center" prop="callerUsername" width="130" show-overflow-tooltip />
|
||||
<el-table-column label="接口地址" prop="apiUrl" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column label="方法" align="center" prop="httpMethod" width="90">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" effect="plain">{{ scope.row.httpMethod }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="HTTP状态" align="center" prop="responseStatus" width="100">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.responseStatus == null ? '-' : scope.row.responseStatus }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="HTTP调用状态" align="center" prop="callStatus" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.callStatus === '0'" type="success" size="mini">成功</el-tag>
|
||||
<el-tag v-else type="danger" size="mini">失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" align="right" prop="costTime" width="110">
|
||||
<template slot-scope="scope">{{ scope.row.costTime }} 毫秒</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调用时间" align="center" prop="callTime" width="165">
|
||||
<template slot-scope="scope">{{ parseTime(scope.row.callTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="90">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-view"
|
||||
v-hasPermi="['monitor:apilog:query']"
|
||||
@click="handleView(scope.row)"
|
||||
>详细</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
:page.sync="queryParams.pageNum"
|
||||
:limit.sync="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
title="接口日志详细"
|
||||
:visible.sync="open"
|
||||
width="90%"
|
||||
custom-class="api-log-dialog"
|
||||
append-to-body
|
||||
>
|
||||
<div v-loading="detailLoading" class="api-log-detail">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="调用账号">{{ detail.callerUsername || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ detail.callerUserId == null ? '-' : detail.callerUserId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="HTTP方法">{{ detail.httpMethod || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Content-Type">{{ detail.contentType || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="HTTP状态">{{ detail.responseStatus == null ? '-' : detail.responseStatus }}</el-descriptions-item>
|
||||
<el-descriptions-item label="HTTP调用状态">{{ detail.callStatus === '0' ? '成功' : '失败' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时">{{ detail.costTime == null ? '-' : detail.costTime + ' 毫秒' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="调用时间">{{ parseTime(detail.callTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="接口地址" :span="2">{{ detail.apiUrl || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<section class="detail-section">
|
||||
<h4>请求头</h4>
|
||||
<pre class="log-content">{{ formatLogContent(detail.requestHeaders) }}</pre>
|
||||
</section>
|
||||
<section class="detail-section">
|
||||
<h4>请求参数</h4>
|
||||
<pre class="log-content">{{ formatLogContent(detail.requestParams) }}</pre>
|
||||
</section>
|
||||
<section class="detail-section">
|
||||
<h4>响应头</h4>
|
||||
<pre class="log-content">{{ formatLogContent(detail.responseHeaders) }}</pre>
|
||||
</section>
|
||||
<section class="detail-section">
|
||||
<h4>原始返回正文</h4>
|
||||
<pre class="log-content log-content-large">{{ formatLogContent(detail.responseBody) }}</pre>
|
||||
</section>
|
||||
<section v-if="detail.errorMsg" class="detail-section">
|
||||
<h4 class="error-title">异常信息</h4>
|
||||
<pre class="log-content error-content">{{ formatLogContent(detail.errorMsg) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="open = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { js_beautify } from 'js-beautify'
|
||||
import { getApiLog, listApiLog } from '@/api/monitor/apilog'
|
||||
|
||||
export default {
|
||||
name: 'ApiLog',
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
detailLoading: false,
|
||||
showSearch: true,
|
||||
total: 0,
|
||||
list: [],
|
||||
open: false,
|
||||
detail: {},
|
||||
dateRange: [],
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
apiUrl: undefined,
|
||||
httpMethod: undefined,
|
||||
callStatus: undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true
|
||||
const query = { ...this.queryParams }
|
||||
if (this.dateRange && this.dateRange.length === 2) {
|
||||
query.beginTime = this.dateRange[0]
|
||||
query.endTime = this.dateRange[1]
|
||||
}
|
||||
listApiLog(query).then(response => {
|
||||
this.list = response.rows
|
||||
this.total = response.total
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.dateRange = []
|
||||
this.resetForm('queryForm')
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
formatLogContent(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-'
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return js_beautify(value, { indent_size: 2 })
|
||||
} catch (error) {
|
||||
return value
|
||||
}
|
||||
},
|
||||
handleView(row) {
|
||||
this.open = true
|
||||
this.detail = {}
|
||||
this.detailLoading = true
|
||||
getApiLog(row.logId).then(response => {
|
||||
this.detail = response.data || {}
|
||||
}).finally(() => {
|
||||
this.detailLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-log-detail {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
::v-deep .api-log-dialog {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
::v-deep .api-log-dialog .el-descriptions-item__content {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 8px;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
background: #f7f8fa;
|
||||
color: #303133;
|
||||
font-family: Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-content-large {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: #c03639 !important;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
border-color: #f3c5c7;
|
||||
background: #fff6f6;
|
||||
color: #9f2d30;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,27 @@ const source = fs.readFileSync(
|
||||
);
|
||||
|
||||
[
|
||||
"未提交账户",
|
||||
"被调查主体未提交分析的同名账户",
|
||||
"涉及主体数",
|
||||
"未提交账户数",
|
||||
"流入总额",
|
||||
"流出总额",
|
||||
"流入/流出差额",
|
||||
"交易占比较高",
|
||||
"导出流水",
|
||||
"handleUnsubmittedAccountCardClick",
|
||||
"getOverviewUnsubmittedAccountFlows",
|
||||
"unsubmittedFlowVisible",
|
||||
"unsubmittedSubjectTotal",
|
||||
"流水明细",
|
||||
"本方账户/主体",
|
||||
"对方名称/账户",
|
||||
"摘要/交易类型",
|
||||
"handleSuspiciousTransactionExport",
|
||||
"ccdi/project/overview/suspicious-transactions/export",
|
||||
"涉疑交易明细_",
|
||||
"当前项目暂无未提交账户",
|
||||
"异常账户人员信息",
|
||||
"账号",
|
||||
"开户人",
|
||||
@@ -22,3 +43,13 @@ const source = fs.readFileSync(
|
||||
"涉及金额",
|
||||
"当前项目暂无异常账户人员信息",
|
||||
].forEach((token) => assert(source.includes(token), token));
|
||||
|
||||
assert(
|
||||
!source.includes("ccdi/project/overview/risk-details/export"),
|
||||
"风险明细区块不应再调用综合风险明细导出"
|
||||
);
|
||||
|
||||
assert(
|
||||
!source.includes("已排除银行贷款类账号"),
|
||||
"未提交账户说明不应展示贷款排除文案"
|
||||
);
|
||||
|
||||
75
sql/migration/2026-07-20-add-external-api-log.sql
Normal file
75
sql/migration/2026-07-20-add-external-api-log.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- 业务外部接口调用日志及菜单
|
||||
-- 可重复执行,不自动授予普通角色权限
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_api_log (
|
||||
log_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '日志主键',
|
||||
caller_user_id BIGINT NULL COMMENT '调用发起用户ID',
|
||||
caller_username VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '调用发起账号',
|
||||
api_url TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '接口完整地址',
|
||||
http_method VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'HTTP方法',
|
||||
content_type VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '请求内容类型',
|
||||
request_headers LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整请求头',
|
||||
request_params LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整请求参数',
|
||||
response_status INT NULL COMMENT 'HTTP响应状态码',
|
||||
response_headers LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整响应头',
|
||||
response_body LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整响应正文',
|
||||
call_status CHAR(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'HTTP调用状态(0成功 1失败)',
|
||||
error_msg LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '异常信息',
|
||||
cost_time BIGINT NOT NULL DEFAULT 0 COMMENT 'HTTP调用耗时(毫秒)',
|
||||
call_time DATETIME NOT NULL COMMENT '调用开始时间',
|
||||
PRIMARY KEY (log_id),
|
||||
KEY idx_sys_api_log_time (call_time, log_id),
|
||||
KEY idx_sys_api_log_status_time (call_status, call_time, log_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='业务外部接口调用日志';
|
||||
|
||||
SET @log_parent_id = (
|
||||
SELECT menu_id
|
||||
FROM sys_menu
|
||||
WHERE menu_name = '日志管理'
|
||||
AND menu_type = 'M'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon,
|
||||
create_by, create_time, update_by, update_time, remark
|
||||
)
|
||||
SELECT
|
||||
'接口日志', @log_parent_id, 3, 'apilog', 'monitor/apilog/index', '', '',
|
||||
1, 0, 'C', '0', '0', 'monitor:apilog:list', 'log',
|
||||
'admin', NOW(), '', NULL, '业务外部接口日志菜单'
|
||||
FROM dual
|
||||
WHERE @log_parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_menu
|
||||
WHERE parent_id = @log_parent_id
|
||||
AND path = 'apilog'
|
||||
);
|
||||
|
||||
SET @api_log_menu_id = (
|
||||
SELECT menu_id
|
||||
FROM sys_menu
|
||||
WHERE parent_id = @log_parent_id
|
||||
AND path = 'apilog'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon,
|
||||
create_by, create_time, update_by, update_time, remark
|
||||
)
|
||||
SELECT
|
||||
'接口日志查询', @api_log_menu_id, 1, '#', '', '', '',
|
||||
1, 0, 'F', '0', '0', 'monitor:apilog:query', '#',
|
||||
'admin', NOW(), '', NULL, ''
|
||||
FROM dual
|
||||
WHERE @api_log_menu_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_menu
|
||||
WHERE parent_id = @api_log_menu_id
|
||||
AND perms = 'monitor:apilog:query'
|
||||
);
|
||||
Reference in New Issue
Block a user