11 Commits

Author SHA1 Message Date
wkc
5d004a66e8 feat: 接入新华社工商信息同步 2026-07-31 09:27:54 +08:00
wjj
2bcba71259 修复金综流水多文件拉取处理 2026-07-21 16:42:45 +08:00
wkc
ffaa2fb2a0 test 2026-07-21 09:01:45 +08:00
wkc
dc8055ce7a 删除征信解析后的本地文件 2026-07-20 15:55:28 +08:00
wkc
90f483f427 更新agents 2026-07-20 15:47:06 +08:00
wkc
2d9828ea5f 新增业务外部接口日志管理 2026-07-20 15:47:06 +08:00
wjj
0a0313af7c Merge remote-tracking branch 'origin/dev-ui' into dev-ui 2026-07-20 14:37:45 +08:00
wjj
175a50bf96 新增流水明细对手方汇总查询 2026-07-20 14:29:23 +08:00
wjj
7fec8e21be 新增未提交账户风险明细 2026-07-20 14:26:29 +08:00
wjj
17628486ae 支持拉取行内和金综流水 2026-07-20 13:07:49 +08:00
wjj
dd424cca50 新增结果总览未提交账户展示 2026-07-20 11:50:45 +08:00
144 changed files with 6662 additions and 675 deletions

View File

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

View File

@@ -57,12 +57,6 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ccdi-lsfx</artifactId>
<version>3.9.1</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@@ -0,0 +1,23 @@
package com.ruoyi.info.collection.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/** 工商信息同步专用线程池。 */
@Configuration
public class EnterpriseProfileExecutorConfig {
@Bean("enterpriseProfileExecutor")
public Executor enterpriseProfileExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(3);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("enterprise-profile-");
executor.initialize();
return executor;
}
}

View File

@@ -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 = "查询征信维护列表")

View File

@@ -8,6 +8,7 @@ import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO;
@@ -18,6 +19,8 @@ import com.ruoyi.info.collection.domain.vo.ImportResultVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoService;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.lsfx.domain.CallerContext;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -56,6 +59,9 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
@Resource
private ICcdiEnterpriseBaseInfoImportService enterpriseBaseInfoImportService;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Operation(summary = "查询实体库列表")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:list')")
@GetMapping("/list")
@@ -73,12 +79,36 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
return success(enterpriseBaseInfoService.selectEnterpriseBaseInfoById(socialCreditCode));
}
@Operation(summary = "重新查询实体工商信息")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:refresh')")
@PostMapping("/enterprise-profile/refresh/{socialCreditCode}")
public AjaxResult refreshEnterpriseProfile(@PathVariable String socialCreditCode) {
enterpriseEntitySyncService.refresh(CallerContext.from(SecurityUtils.getLoginUser()), socialCreditCode);
return AjaxResult.success("工商信息更新成功");
}
@Operation(summary = "启动实体工商信息历史补全")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:backfill')")
@PostMapping("/enterprise-profile/backfill")
public AjaxResult startEnterpriseProfileBackfill() {
String taskId = enterpriseEntitySyncService.startBackfill(CallerContext.from(SecurityUtils.getLoginUser()));
return AjaxResult.success("工商信息补全任务已提交", taskId);
}
@Operation(summary = "查询实体工商信息历史补全状态")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:backfill')")
@GetMapping("/enterprise-profile/backfill/{taskId}")
public AjaxResult getEnterpriseProfileBackfillStatus(@PathVariable String taskId) {
return AjaxResult.success(enterpriseEntitySyncService.getBackfillStatus(taskId));
}
@Operation(summary = "新增实体库信息")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:add')")
@Log(title = "实体库管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody CcdiEnterpriseBaseInfoAddDTO addDTO) {
return toAjax(enterpriseBaseInfoService.insertEnterpriseBaseInfo(addDTO));
return toAjax(enterpriseBaseInfoService.insertEnterpriseBaseInfo(
CallerContext.from(SecurityUtils.getLoginUser()), addDTO));
}
@Operation(summary = "修改实体库信息")
@@ -86,7 +116,8 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
@Log(title = "实体库管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody CcdiEnterpriseBaseInfoEditDTO editDTO) {
return toAjax(enterpriseBaseInfoService.updateEnterpriseBaseInfo(editDTO));
return toAjax(enterpriseBaseInfoService.updateEnterpriseBaseInfo(
CallerContext.from(SecurityUtils.getLoginUser()), editDTO));
}
@Operation(summary = "删除实体库信息")
@@ -113,7 +144,8 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
return error("至少需要一条数据");
}
String taskId = enterpriseBaseInfoService.importEnterpriseBaseInfo(list);
String taskId = enterpriseBaseInfoService.importEnterpriseBaseInfo(
CallerContext.from(SecurityUtils.getLoginUser()), list);
ImportResultVO result = new ImportResultVO();
result.setTaskId(taskId);
result.setStatus("PROCESSING");

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
@@ -48,6 +49,30 @@ public class CcdiEnterpriseBaseInfo implements Serializable {
/** 法定代表人 */
private String legalRepresentative;
/** 注册资本 */
private BigDecimal registeredCapital;
/** 注册资本单位 */
private String registeredCapitalUnit;
/** 注册日期 */
private Date registerDate;
/** 区域编码 */
private String regionCode;
/** 区域名称 */
private String regionName;
/** 从业人数 */
private Integer employeeCount;
/** 工商缓存ID */
private Long cacheInfoId;
/** 工商同步时间 */
private Date enterpriseSyncTime;
/** 法定代表人证件类型 */
private String legalCertType;

View File

@@ -0,0 +1,21 @@
package com.ruoyi.info.collection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/** 工商信息原始响应缓存。 */
@Data
@TableName("ccdi_enterpriseinfo_query_cache")
public class CcdiEnterpriseInfoQueryCache {
@TableId(type = IdType.AUTO)
private Long infoId;
private String queryParam;
private String queryResult;
private String queryType;
private Date createdDate;
private Date validDate;
}

View File

@@ -0,0 +1,31 @@
package com.ruoyi.info.collection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 实体库企业完整股东。 */
@Data
@TableName("ccdi_enterprise_shareholder")
public class CcdiEnterpriseShareholder {
@TableId(type = IdType.AUTO)
private Long shareholderId;
private String socialCreditCode;
private Integer shareholderSeq;
private String shareholderName;
private String shareholderType;
private String shareholderCreditCode;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
private Long cacheInfoId;
private Date syncTime;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.info.collection.domain.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/** 统一工商信息结构。 */
@Data
public class EnterpriseProfile {
private String creditCode;
private String enterpriseName;
private BigDecimal registeredCapital;
private String registeredCapitalUnit;
private LocalDate registerDate;
private LocalDate establishDate;
private String industryCode;
private String industryName;
private String organizationTypeCode;
private String organizationTypeName;
private String regionCode;
private String regionName;
private String registerAddress;
private Integer employeeCount;
private String legalRepresentative;
private List<EnterpriseShareholderProfile> shareholders = new ArrayList<>();
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.info.collection.domain.model;
import java.util.Date;
/** 统一工商查询结果。 */
public record EnterpriseProfileQueryResult(
EnterpriseProfile profile,
Long cacheInfoId,
Date validDate,
boolean fromCache
) {
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.info.collection.domain.model;
import lombok.Data;
import java.math.BigDecimal;
/** 工商股东结构。 */
@Data
public class EnterpriseShareholderProfile {
private Integer sequence;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
}

View File

@@ -5,7 +5,10 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 实体库管理 VO
@@ -47,6 +50,39 @@ public class CcdiEnterpriseBaseInfoVO implements Serializable {
@Schema(description = "法定代表人")
private String legalRepresentative;
@Schema(description = "注册资本")
private BigDecimal registeredCapital;
@Schema(description = "注册资本单位")
private String registeredCapitalUnit;
@Schema(description = "注册日期")
private Date registerDate;
@Schema(description = "区域编码")
private String regionCode;
@Schema(description = "区域名称")
private String regionName;
@Schema(description = "从业人数")
private Integer employeeCount;
@Schema(description = "工商缓存ID")
private Long cacheInfoId;
@Schema(description = "工商同步时间")
private Date enterpriseSyncTime;
@Schema(description = "缓存有效期")
private Date enterpriseCacheValidDate;
@Schema(description = "是否允许重新查询")
private Boolean canRefreshEnterpriseProfile;
@Schema(description = "完整股东列表")
private List<EnterpriseShareholderVO> shareholders = new ArrayList<>();
@Schema(description = "法定代表人证件类型")
private String legalCertType;

View File

@@ -0,0 +1,16 @@
package com.ruoyi.info.collection.domain.vo;
import lombok.Data;
/** 工商信息历史补全任务状态。 */
@Data
public class EnterpriseProfileSyncTaskVO {
private String taskId;
private String status;
private Integer totalCount;
private Integer completedCount;
private Integer successCount;
private Integer skippedCount;
private Integer failureCount;
private String message;
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.info.collection.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/** 企业股东明细。 */
@Data
@Schema(description = "企业股东明细")
public class EnterpriseShareholderVO {
private Integer shareholderSeq;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
}

View File

@@ -52,4 +52,8 @@ public interface CcdiEnterpriseBaseInfoMapper extends BaseMapper<CcdiEnterpriseB
* @return 更新行数
*/
int updateBatch(List<CcdiEnterpriseBaseInfo> list);
int updateEnterpriseProfile(CcdiEnterpriseBaseInfo entity);
List<CcdiEnterpriseBaseInfo> selectEnterpriseProfileCandidates();
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.info.collection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
/** 工商信息缓存 Mapper。 */
public interface CcdiEnterpriseInfoQueryCacheMapper extends BaseMapper<CcdiEnterpriseInfoQueryCache> {
CcdiEnterpriseInfoQueryCache selectValidCache(@Param("queryParam") String queryParam,
@Param("queryType") String queryType,
@Param("now") Date now);
int upsertCache(CcdiEnterpriseInfoQueryCache cache);
CcdiEnterpriseInfoQueryCache selectCache(@Param("queryParam") String queryParam,
@Param("queryType") String queryType);
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.info.collection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.info.collection.domain.CcdiEnterpriseShareholder;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 实体工商股东 Mapper。 */
public interface CcdiEnterpriseShareholderMapper extends BaseMapper<CcdiEnterpriseShareholder> {
List<CcdiEnterpriseShareholder> selectByCreditCode(@Param("creditCode") String creditCode);
int deleteByCreditCode(@Param("creditCode") String creditCode);
int insertBatch(@Param("list") List<CcdiEnterpriseShareholder> list);
}

View File

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

View File

@@ -4,6 +4,7 @@ import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel;
import com.ruoyi.info.collection.domain.vo.EnterpriseBaseInfoImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.lsfx.domain.CallerContext;
import java.util.List;
import java.util.Set;
@@ -16,7 +17,7 @@ import java.util.Set;
*/
public interface ICcdiEnterpriseBaseInfoImportService {
void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, String userName);
void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, CallerContext caller);
ImportStatusVO getImportStatus(String taskId);

View File

@@ -6,6 +6,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel;
import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO;
import com.ruoyi.lsfx.domain.CallerContext;
import java.util.List;
@@ -22,13 +23,13 @@ public interface ICcdiEnterpriseBaseInfoService {
CcdiEnterpriseBaseInfoVO selectEnterpriseBaseInfoById(String socialCreditCode);
int insertEnterpriseBaseInfo(CcdiEnterpriseBaseInfoAddDTO addDTO);
int insertEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoAddDTO addDTO);
int updateEnterpriseBaseInfo(CcdiEnterpriseBaseInfoEditDTO editDTO);
int updateEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoEditDTO editDTO);
int deleteEnterpriseBaseInfoByIds(String[] socialCreditCodes);
List<CcdiEnterpriseBaseInfoExcel> selectEnterpriseBaseInfoListForExport(CcdiEnterpriseBaseInfoQueryDTO queryDTO);
String importEnterpriseBaseInfo(List<CcdiEnterpriseBaseInfoExcel> excelList);
String importEnterpriseBaseInfo(CallerContext caller, List<CcdiEnterpriseBaseInfoExcel> excelList);
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.vo.EnterpriseProfileSyncTaskVO;
import com.ruoyi.lsfx.domain.CallerContext;
import java.util.List;
/** 实体库工商信息同步服务。 */
public interface IEnterpriseEntitySyncService {
void submitSync(CallerContext caller, String socialCreditCode);
void submitSyncBatch(CallerContext caller, List<String> socialCreditCodes);
void refresh(CallerContext caller, String socialCreditCode);
String startBackfill(CallerContext caller);
EnterpriseProfileSyncTaskVO getBackfillStatus(String taskId);
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.lsfx.domain.CallerContext;
/** 统一工商信息查询服务。 */
public interface IEnterpriseProfileQueryService {
EnterpriseProfileQueryResult query(CallerContext caller, String enterpriseName, boolean forceRefresh);
CcdiEnterpriseInfoQueryCache findCache(String enterpriseName);
}

View File

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

View File

@@ -13,6 +13,8 @@ import com.ruoyi.info.collection.enums.EnterpriseRiskLevel;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
@@ -47,9 +49,12 @@ public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseB
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Override
@Async
public void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, String userName) {
public void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, CallerContext caller) {
List<CcdiEnterpriseBaseInfo> successRecords = new ArrayList<>();
List<EnterpriseBaseInfoImportFailureVO> failures = new ArrayList<>();
Set<String> existingCreditCodes = getExistingCreditCodes(excelList);
@@ -57,7 +62,7 @@ public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseB
for (CcdiEnterpriseBaseInfoExcel excel : excelList) {
try {
CcdiEnterpriseBaseInfo entity = validateAndBuildEntity(excel, existingCreditCodes, processedCreditCodes, userName);
CcdiEnterpriseBaseInfo entity = validateAndBuildEntity(excel, existingCreditCodes, processedCreditCodes, caller.username());
successRecords.add(entity);
processedCreditCodes.add(entity.getSocialCreditCode());
} catch (Exception e) {
@@ -70,6 +75,9 @@ public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseB
if (!successRecords.isEmpty()) {
saveBatch(successRecords, 500);
enterpriseEntitySyncService.submitSyncBatch(caller, successRecords.stream()
.map(CcdiEnterpriseBaseInfo::getSocialCreditCode)
.toList());
}
if (!failures.isEmpty()) {

View File

@@ -2,10 +2,10 @@ package com.ruoyi.info.collection.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.info.collection.domain.CcdiCustEnterpriseRelation;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation;
import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO;
@@ -13,20 +13,27 @@ import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel;
import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO;
import com.ruoyi.info.collection.domain.vo.EnterpriseShareholderVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseRiskLevel;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseShareholderMapper;
import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoService;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.HashMap;
import java.util.List;
@@ -62,6 +69,15 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private CcdiEnterpriseShareholderMapper enterpriseShareholderMapper;
@Resource
private IEnterpriseProfileQueryService enterpriseProfileQueryService;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Override
public Page<CcdiEnterpriseBaseInfoVO> selectEnterpriseBaseInfoPage(Page<CcdiEnterpriseBaseInfoVO> page,
CcdiEnterpriseBaseInfoQueryDTO queryDTO) {
@@ -76,12 +92,21 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
}
CcdiEnterpriseBaseInfoVO vo = new CcdiEnterpriseBaseInfoVO();
BeanUtils.copyProperties(entity, vo);
vo.setShareholders(enterpriseShareholderMapper.selectByCreditCode(socialCreditCode).stream().map(item -> {
EnterpriseShareholderVO shareholder = new EnterpriseShareholderVO();
BeanUtils.copyProperties(item, shareholder);
return shareholder;
}).toList());
CcdiEnterpriseInfoQueryCache cache = enterpriseProfileQueryService.findCache(entity.getEnterpriseName());
vo.setEnterpriseCacheValidDate(cache == null ? null : cache.getValidDate());
vo.setCanRefreshEnterpriseProfile(entity.getEnterpriseSyncTime() == null || cache == null
|| cache.getValidDate() == null || !cache.getValidDate().after(new java.util.Date()));
return vo;
}
@Override
@Transactional
public int insertEnterpriseBaseInfo(CcdiEnterpriseBaseInfoAddDTO addDTO) {
public int insertEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoAddDTO addDTO) {
if (enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode()) != null) {
throw new RuntimeException("该统一社会信用代码已存在");
}
@@ -91,12 +116,16 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
BeanUtils.copyProperties(addDTO, entity);
entity.setStatus(trimToNull(addDTO.getStatus()));
entity.setDataSource(DataSource.MANUAL.getCode());
return enterpriseBaseInfoMapper.insert(entity);
int rows = enterpriseBaseInfoMapper.insert(entity);
if (rows > 0) {
submitSyncAfterCommit(caller, entity.getSocialCreditCode());
}
return rows;
}
@Override
@Transactional
public int updateEnterpriseBaseInfo(CcdiEnterpriseBaseInfoEditDTO editDTO) {
public int updateEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoEditDTO editDTO) {
CcdiEnterpriseBaseInfo existing = enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode());
if (existing == null) {
throw new RuntimeException("实体库记录不存在");
@@ -107,7 +136,11 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
BeanUtils.copyProperties(editDTO, entity);
entity.setStatus(trimToNull(editDTO.getStatus()));
entity.setDataSource(existing.getDataSource());
return enterpriseBaseInfoMapper.updateById(entity);
int rows = enterpriseBaseInfoMapper.updateById(entity);
if (rows > 0 && !java.util.Objects.equals(trimToNull(existing.getEnterpriseName()), trimToNull(entity.getEnterpriseName()))) {
submitSyncAfterCommit(caller, entity.getSocialCreditCode());
}
return rows;
}
@Override
@@ -118,6 +151,7 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
}
for (String socialCreditCode : socialCreditCodes) {
validateDeleteRelations(socialCreditCode);
enterpriseShareholderMapper.deleteByCreditCode(socialCreditCode);
}
return enterpriseBaseInfoMapper.deleteBatchIds(List.of(socialCreditCodes));
}
@@ -134,7 +168,7 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
@Override
@Transactional
public String importEnterpriseBaseInfo(List<CcdiEnterpriseBaseInfoExcel> excelList) {
public String importEnterpriseBaseInfo(CallerContext caller, List<CcdiEnterpriseBaseInfoExcel> excelList) {
String taskId = UUID.randomUUID().toString();
String statusKey = "import:enterpriseBaseInfo:" + taskId;
@@ -151,10 +185,24 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
redisTemplate.opsForHash().putAll(statusKey, statusData);
redisTemplate.expire(statusKey, 7, TimeUnit.DAYS);
enterpriseBaseInfoImportService.importEnterpriseBaseInfoAsync(excelList, taskId, SecurityUtils.getUsername());
enterpriseBaseInfoImportService.importEnterpriseBaseInfoAsync(
excelList, taskId, caller);
return taskId;
}
private void submitSyncAfterCommit(CallerContext caller, String socialCreditCode) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
enterpriseEntitySyncService.submitSync(caller, socialCreditCode);
}
});
} else {
enterpriseEntitySyncService.submitSync(caller, socialCreditCode);
}
}
private LambdaQueryWrapper<CcdiEnterpriseBaseInfo> buildQueryWrapper(CcdiEnterpriseBaseInfoQueryDTO queryDTO) {
LambdaQueryWrapper<CcdiEnterpriseBaseInfo> wrapper = new LambdaQueryWrapper<>();
if (queryDTO == null) {

View File

@@ -0,0 +1,304 @@
package com.ruoyi.info.collection.service.impl;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.CcdiEnterpriseShareholder;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.info.collection.domain.model.EnterpriseShareholderProfile;
import com.ruoyi.info.collection.domain.vo.EnterpriseProfileSyncTaskVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseShareholderMapper;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/** 实体库工商信息同步服务实现。 */
@Service
public class EnterpriseEntitySyncServiceImpl implements IEnterpriseEntitySyncService {
private static final String TASK_PREFIX = "enterprise:profile:backfill:";
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseMapper;
@Resource
private CcdiEnterpriseShareholderMapper shareholderMapper;
@Resource
private IEnterpriseProfileQueryService queryService;
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
@Qualifier("enterpriseProfileExecutor")
private Executor enterpriseProfileExecutor;
@Override
public void submitSync(CallerContext caller, String socialCreditCode) {
submitSyncBatch(caller, List.of(socialCreditCode));
}
@Override
public void submitSyncBatch(CallerContext caller, List<String> socialCreditCodes) {
if (socialCreditCodes == null || socialCreditCodes.isEmpty()) {
return;
}
List<String> normalizedCodes = socialCreditCodes.stream().filter(StringUtils::hasText).distinct().toList();
if (normalizedCodes.isEmpty()) {
return;
}
List<CcdiEnterpriseBaseInfo> entities = enterpriseMapper.selectBatchIds(normalizedCodes);
Map<String, List<CcdiEnterpriseBaseInfo>> groups = entities.stream()
.filter(item -> StringUtils.hasText(item.getEnterpriseName()))
.collect(Collectors.groupingBy(item -> item.getEnterpriseName().trim(), LinkedHashMap::new, Collectors.toList()));
groups.forEach((name, sameNameEntities) -> CompletableFuture.runAsync(() -> {
EnterpriseProfileQueryResult result = queryService.query(caller, name, false);
sameNameEntities.stream()
.filter(item -> result.profile().getCreditCode().equals(item.getSocialCreditCode()))
.findFirst()
.ifPresent(item -> applyProfile(item, result, caller.username()));
}, enterpriseProfileExecutor));
}
@Override
public void refresh(CallerContext caller, String socialCreditCode) {
CcdiEnterpriseBaseInfo entity = requireEntity(socialCreditCode);
CcdiEnterpriseInfoQueryCache cache = queryService.findCache(entity.getEnterpriseName());
boolean cacheValid = cache != null && cache.getValidDate() != null && cache.getValidDate().after(new Date());
if (entity.getEnterpriseSyncTime() != null && cacheValid) {
throw new IllegalStateException("工商缓存仍在有效期内,无需重新查询");
}
SyncOutcome outcome = syncByCreditCode(caller, socialCreditCode, !cacheValid);
if (outcome != SyncOutcome.SUCCESS) {
throw new IllegalStateException("工商信用代码与实体主键不一致");
}
}
@Override
public String startBackfill(CallerContext caller) {
String taskId = UUID.randomUUID().toString().replace("-", "");
List<CcdiEnterpriseBaseInfo> candidates = enterpriseMapper.selectEnterpriseProfileCandidates();
Map<String, List<CcdiEnterpriseBaseInfo>> groups = candidates.stream()
.filter(item -> StringUtils.hasText(item.getEnterpriseName()))
.collect(Collectors.groupingBy(item -> item.getEnterpriseName().trim(), LinkedHashMap::new, Collectors.toList()));
initializeTask(taskId, groups.size());
CompletableFuture.runAsync(() -> runBackfill(taskId, caller, groups));
return taskId;
}
@Override
public EnterpriseProfileSyncTaskVO getBackfillStatus(String taskId) {
Map<Object, Object> status = redisTemplate.opsForHash().entries(taskKey(taskId));
if (status.isEmpty()) {
throw new IllegalArgumentException("任务不存在或已过期");
}
EnterpriseProfileSyncTaskVO vo = new EnterpriseProfileSyncTaskVO();
vo.setTaskId(string(status.get("taskId")));
vo.setStatus(string(status.get("status")));
vo.setTotalCount(number(status.get("totalCount")));
vo.setCompletedCount(number(status.get("completedCount")));
vo.setSuccessCount(number(status.get("successCount")));
vo.setSkippedCount(number(status.get("skippedCount")));
vo.setFailureCount(number(status.get("failureCount")));
vo.setMessage(string(status.get("message")));
return vo;
}
private void runBackfill(String taskId, CallerContext caller,
Map<String, List<CcdiEnterpriseBaseInfo>> groups) {
AtomicInteger completed = new AtomicInteger();
AtomicInteger success = new AtomicInteger();
AtomicInteger skipped = new AtomicInteger();
AtomicInteger failure = new AtomicInteger();
List<Map.Entry<String, List<CcdiEnterpriseBaseInfo>>> entries = new ArrayList<>(groups.entrySet());
for (int start = 0; start < entries.size(); start += 100) {
int end = Math.min(start + 100, entries.size());
List<CompletableFuture<Void>> futures = entries.subList(start, end).stream()
.map(entry -> CompletableFuture.runAsync(() -> {
try {
EnterpriseProfileQueryResult result = queryService.query(caller, entry.getKey(), false);
CcdiEnterpriseBaseInfo match = entry.getValue().stream()
.filter(item -> result.profile().getCreditCode().equals(item.getSocialCreditCode()))
.findFirst().orElse(null);
if (match == null) {
skipped.incrementAndGet();
} else {
applyProfile(match, result, caller.username());
success.incrementAndGet();
}
} catch (Exception e) {
failure.incrementAndGet();
} finally {
completed.incrementAndGet();
updateTask(taskId, "PROCESSING", completed.get(), success.get(), skipped.get(), failure.get());
}
}, enterpriseProfileExecutor)).toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
updateTask(taskId, "COMPLETED", completed.get(), success.get(), skipped.get(), failure.get());
}
private SyncOutcome syncByCreditCode(CallerContext caller, String socialCreditCode,
boolean forceRefresh) {
CcdiEnterpriseBaseInfo entity = requireEntity(socialCreditCode);
EnterpriseProfileQueryResult result = queryService.query(caller, entity.getEnterpriseName(), forceRefresh);
if (!result.profile().getCreditCode().equals(entity.getSocialCreditCode())) {
return SyncOutcome.SKIPPED;
}
applyProfile(entity, result, caller.username());
return SyncOutcome.SUCCESS;
}
private void applyProfile(CcdiEnterpriseBaseInfo existing, EnterpriseProfileQueryResult result, String username) {
transactionTemplate.executeWithoutResult(status -> {
EnterpriseProfile profile = result.profile();
Date now = new Date();
CcdiEnterpriseBaseInfo update = new CcdiEnterpriseBaseInfo();
update.setSocialCreditCode(existing.getSocialCreditCode());
update.setEnterpriseName(existing.getEnterpriseName().trim());
update.setEnterpriseType(profile.getOrganizationTypeName());
update.setIndustryClass(profile.getIndustryCode());
update.setIndustryName(profile.getIndustryName());
update.setEstablishDate(toDate(profile.getEstablishDate()));
update.setRegisterAddress(profile.getRegisterAddress());
update.setLegalRepresentative(profile.getLegalRepresentative());
update.setRegisteredCapital(profile.getRegisteredCapital());
update.setRegisteredCapitalUnit(profile.getRegisteredCapitalUnit());
update.setRegisterDate(toDate(profile.getRegisterDate()));
update.setRegionCode(profile.getRegionCode());
update.setRegionName(profile.getRegionName());
update.setEmployeeCount(profile.getEmployeeCount());
update.setCacheInfoId(result.cacheInfoId());
update.setEnterpriseSyncTime(now);
update.setDataSource(DataSource.API.getCode());
update.setUpdatedBy(username);
setTopFive(update, profile.getShareholders());
if (enterpriseMapper.updateEnterpriseProfile(update) != 1) {
throw new IllegalStateException("实体工商信息更新失败");
}
shareholderMapper.deleteByCreditCode(existing.getSocialCreditCode());
List<CcdiEnterpriseShareholder> shareholders = profile.getShareholders().stream()
.map(item -> toEntityShareholder(existing.getSocialCreditCode(), item, result.cacheInfoId(), now, username))
.toList();
if (!shareholders.isEmpty()) {
shareholderMapper.insertBatch(shareholders);
}
});
}
private CcdiEnterpriseShareholder toEntityShareholder(String creditCode,
EnterpriseShareholderProfile item,
Long cacheInfoId,
Date now,
String username) {
CcdiEnterpriseShareholder shareholder = new CcdiEnterpriseShareholder();
shareholder.setSocialCreditCode(creditCode);
shareholder.setShareholderSeq(item.getSequence());
shareholder.setShareholderName(item.getShareholderName());
shareholder.setStockPercent(item.getStockPercent());
shareholder.setSubscribedCapital(item.getSubscribedCapital());
shareholder.setCapitalUnit(item.getCapitalUnit());
shareholder.setCacheInfoId(cacheInfoId);
shareholder.setSyncTime(now);
shareholder.setCreateBy(username);
shareholder.setCreateTime(now);
shareholder.setUpdateBy(username);
shareholder.setUpdateTime(now);
return shareholder;
}
private void setTopFive(CcdiEnterpriseBaseInfo entity, List<EnterpriseShareholderProfile> shareholders) {
String[] names = new String[5];
for (int i = 0; i < Math.min(names.length, shareholders.size()); i++) {
names[i] = shareholders.get(i).getShareholderName();
}
entity.setShareholder1(names[0]);
entity.setShareholder2(names[1]);
entity.setShareholder3(names[2]);
entity.setShareholder4(names[3]);
entity.setShareholder5(names[4]);
}
private CcdiEnterpriseBaseInfo requireEntity(String socialCreditCode) {
CcdiEnterpriseBaseInfo entity = enterpriseMapper.selectById(socialCreditCode);
if (entity == null) {
throw new IllegalArgumentException("实体库记录不存在");
}
if (!StringUtils.hasText(entity.getEnterpriseName())) {
throw new IllegalArgumentException("企业名称不能为空");
}
return entity;
}
private Date toDate(java.time.LocalDate value) {
return value == null ? null : Date.from(value.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
private void initializeTask(String taskId, int totalCount) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("taskId", taskId);
values.put("status", "PROCESSING");
values.put("totalCount", totalCount);
values.put("completedCount", 0);
values.put("successCount", 0);
values.put("skippedCount", 0);
values.put("failureCount", 0);
values.put("message", "正在补全工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
redisTemplate.expire(taskKey(taskId), 7, TimeUnit.DAYS);
}
private void updateTask(String taskId, String status, int completed, int success, int skipped, int failure) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("status", status);
values.put("completedCount", completed);
values.put("successCount", success);
values.put("skippedCount", skipped);
values.put("failureCount", failure);
values.put("message", "COMPLETED".equals(status) ? "工商信息补全完成" : "正在补全工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
}
private String taskKey(String taskId) {
return TASK_PREFIX + taskId;
}
private String string(Object value) {
return value == null ? null : value.toString();
}
private Integer number(Object value) {
return value == null ? 0 : Integer.valueOf(value.toString());
}
private enum SyncOutcome {
SUCCESS, SKIPPED
}
}

View File

@@ -0,0 +1,92 @@
package com.ruoyi.info.collection.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseInfoQueryCacheMapper;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.info.collection.service.support.XinhuaEnterpriseProfileParser;
import com.ruoyi.lsfx.client.XinhuaEnterpriseClient;
import com.ruoyi.lsfx.domain.CallerContext;
import com.ruoyi.lsfx.domain.response.XinhuaEnterpriseQueryResult;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
/** 统一工商信息查询服务实现。 */
@Service
public class EnterpriseProfileQueryServiceImpl implements IEnterpriseProfileQueryService {
public static final String QUERY_TYPE = "EnterpriseProfile";
private static final int CACHE_VALID_DAYS = 180;
@Resource
private CcdiEnterpriseInfoQueryCacheMapper cacheMapper;
@Resource
private XinhuaEnterpriseClient xinhuaEnterpriseClient;
@Resource
private XinhuaEnterpriseProfileParser profileParser;
@Resource
private ObjectMapper objectMapper;
@Override
public EnterpriseProfileQueryResult query(CallerContext caller, String enterpriseName, boolean forceRefresh) {
String normalizedName = normalize(enterpriseName);
if (!StringUtils.hasText(normalizedName)) {
throw new IllegalArgumentException("企业名称不能为空");
}
Date now = new Date();
if (!forceRefresh) {
CcdiEnterpriseInfoQueryCache cache = cacheMapper.selectValidCache(normalizedName, QUERY_TYPE, now);
if (cache != null) {
return fromCache(cache);
}
}
XinhuaEnterpriseQueryResult external = xinhuaEnterpriseClient.query(caller, normalizedName);
EnterpriseProfile profile = profileParser.parse(external.profile());
Date successTime = new Date();
Date validDate = Date.from(LocalDateTime.ofInstant(successTime.toInstant(), ZoneId.systemDefault())
.plusDays(CACHE_VALID_DAYS)
.atZone(ZoneId.systemDefault()).toInstant());
CcdiEnterpriseInfoQueryCache cache = new CcdiEnterpriseInfoQueryCache();
cache.setQueryParam(normalizedName);
cache.setQueryType(QUERY_TYPE);
cache.setQueryResult(external.rawJson());
cache.setCreatedDate(successTime);
cache.setValidDate(validDate);
cacheMapper.upsertCache(cache);
CcdiEnterpriseInfoQueryCache saved = cacheMapper.selectCache(normalizedName, QUERY_TYPE);
return new EnterpriseProfileQueryResult(profile, saved.getInfoId(), saved.getValidDate(), false);
}
@Override
public CcdiEnterpriseInfoQueryCache findCache(String enterpriseName) {
String normalizedName = normalize(enterpriseName);
return StringUtils.hasText(normalizedName) ? cacheMapper.selectCache(normalizedName, QUERY_TYPE) : null;
}
private EnterpriseProfileQueryResult fromCache(CcdiEnterpriseInfoQueryCache cache) {
try {
JsonNode root = objectMapper.readTree(cache.getQueryResult());
EnterpriseProfile profile = profileParser.parse(root.path("data").path("mappingOutputFields"));
return new EnterpriseProfileQueryResult(profile, cache.getInfoId(), cache.getValidDate(), true);
} catch (Exception e) {
throw new IllegalStateException("工商缓存内容无法解析: infoId=" + cache.getInfoId(), e);
}
}
private String normalize(String value) {
return value == null ? null : value.trim();
}
}

View File

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

View File

@@ -4,10 +4,14 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -27,6 +31,9 @@ public class EnterpriseAutoFillService {
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
public record EnterpriseFillItem(
String socialCreditCode,
String enterpriseName,
@@ -63,6 +70,7 @@ public class EnterpriseAutoFillService {
return;
}
insertBatchIgnoreDuplicate(missingEntities);
submitSyncAfterCommit(missingEntities);
}
private Map<String, EnterpriseFillItem> normalizeItems(List<EnterpriseFillItem> items) {
@@ -121,6 +129,23 @@ public class EnterpriseAutoFillService {
}
}
private void submitSyncAfterCommit(List<CcdiEnterpriseBaseInfo> entities) {
String username = entities.stream().map(CcdiEnterpriseBaseInfo::getCreatedBy)
.filter(StringUtils::isNotEmpty).findFirst().orElse("system");
CallerContext caller = CallerContext.of(null, username);
List<String> creditCodes = entities.stream().map(CcdiEnterpriseBaseInfo::getSocialCreditCode).toList();
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
enterpriseEntitySyncService.submitSyncBatch(caller, creditCodes);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
enterpriseEntitySyncService.submitSyncBatch(caller, creditCodes);
}
});
}
private String trimToNull(String value) {
if (StringUtils.isEmpty(value)) {
return null;

View File

@@ -0,0 +1,141 @@
package com.ruoyi.info.collection.service.support;
import com.fasterxml.jackson.databind.JsonNode;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseShareholderProfile;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** 将新华社 mappingOutputFields 解析为统一工商结构。 */
@Component
public class XinhuaEnterpriseProfileParser {
public EnterpriseProfile parse(JsonNode node) {
if (node == null || !node.isObject() || node.isEmpty()) {
throw new IllegalArgumentException("工商信息对象不能为空");
}
EnterpriseProfile profile = new EnterpriseProfile();
profile.setCreditCode(text(node, "creditCode"));
profile.setEnterpriseName(text(node, "enterpriseName"));
JsonNode capital = node.path("regCapitalAmount");
profile.setRegisteredCapital(decimal(text(capital, "regist_capi_value"), false));
profile.setRegisteredCapitalUnit(text(capital, "regist_capi_unit"));
profile.setRegisterDate(date(text(node, "estiblishTime")));
profile.setEstablishDate(date(text(node, "fromTime")));
JsonNode industry = node.path("industry");
profile.setIndustryCode(text(industry, "industry_code"));
profile.setIndustryName(text(industry, "industry"));
JsonNode orgType = node.path("companyOrgType");
if (orgType.isObject()) {
profile.setOrganizationTypeCode(text(orgType, "econ_kind_code"));
profile.setOrganizationTypeName(text(orgType, "econ_kind"));
} else if (orgType.isTextual()) {
profile.setOrganizationTypeName(normalize(orgType.asText()));
}
JsonNode city = node.path("baseCity");
profile.setRegionCode(text(city, "province_code"));
profile.setRegionName(regionName(city));
profile.setRegisterAddress(text(node, "regLocation"));
profile.setEmployeeCount(integer(node.get("employeeCount")));
profile.setLegalRepresentative(text(node, "legalPersonName"));
profile.setShareholders(shareholders(node.path("holders")));
return profile;
}
private List<EnterpriseShareholderProfile> shareholders(JsonNode holders) {
List<EnterpriseShareholderProfile> result = new ArrayList<>();
if (!holders.isArray()) {
return result;
}
int sequence = 1;
for (JsonNode holder : holders) {
String name = text(holder, "stock_name");
if (!StringUtils.hasText(name)) {
continue;
}
EnterpriseShareholderProfile item = new EnterpriseShareholderProfile();
item.setSequence(sequence++);
item.setShareholderName(name);
item.setStockPercent(decimal(text(holder, "stock_percent"), true));
item.setSubscribedCapital(decimal(text(holder, "should_capi"), false));
item.setCapitalUnit("万元");
result.add(item);
}
return result;
}
private String regionName(JsonNode city) {
Set<String> values = new LinkedHashSet<>();
add(values, text(city, "province"));
add(values, text(city, "city"));
add(values, text(city, "county"));
return values.isEmpty() ? null : String.join("", values);
}
private void add(Set<String> values, String value) {
if (StringUtils.hasText(value)) {
values.add(value);
}
}
private String text(JsonNode node, String field) {
if (node == null || !node.isObject()) {
return null;
}
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : normalize(value.asText());
}
private String normalize(String value) {
return value == null ? null : value.trim();
}
private BigDecimal decimal(String value, boolean percentage) {
if (!StringUtils.hasText(value)) {
return null;
}
String normalized = value.replace(",", "").trim();
if (percentage && normalized.endsWith("%")) {
normalized = normalized.substring(0, normalized.length() - 1).trim();
}
try {
return new BigDecimal(normalized);
} catch (NumberFormatException e) {
return null;
}
}
private Integer integer(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
try {
return Integer.valueOf(value.asText().trim());
} catch (NumberFormatException e) {
return null;
}
}
private LocalDate date(String value) {
if (!StringUtils.hasText(value) || value.length() < 10) {
return null;
}
try {
return LocalDate.parse(value.substring(0, 10));
} catch (DateTimeParseException e) {
return null;
}
}
}

View File

@@ -14,6 +14,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="establishDate" column="establish_date"/>
<result property="registerAddress" column="register_address"/>
<result property="legalRepresentative" column="legal_representative"/>
<result property="registeredCapital" column="registered_capital"/>
<result property="registeredCapitalUnit" column="registered_capital_unit"/>
<result property="registerDate" column="register_date"/>
<result property="regionCode" column="region_code"/>
<result property="regionName" column="region_name"/>
<result property="employeeCount" column="employee_count"/>
<result property="cacheInfoId" column="cache_info_id"/>
<result property="enterpriseSyncTime" column="enterprise_sync_time"/>
<result property="legalCertType" column="legal_cert_type"/>
<result property="legalCertNo" column="legal_cert_no"/>
<result property="shareholder1" column="shareholder1"/>
@@ -39,6 +47,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
establish_date,
register_address,
legal_representative,
registered_capital,
registered_capital_unit,
register_date,
region_code,
region_name,
employee_count,
cache_info_id,
enterprise_sync_time,
legal_cert_type,
legal_cert_no,
shareholder1,
@@ -178,4 +194,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</update>
<update id="updateEnterpriseProfile" parameterType="com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo">
update ccdi_enterprise_base_info
set enterprise_type = #{enterpriseType},
industry_class = #{industryClass},
industry_name = #{industryName},
establish_date = #{establishDate},
register_address = #{registerAddress},
legal_representative = #{legalRepresentative},
registered_capital = #{registeredCapital},
registered_capital_unit = #{registeredCapitalUnit},
register_date = #{registerDate},
region_code = #{regionCode},
region_name = #{regionName},
employee_count = #{employeeCount},
cache_info_id = #{cacheInfoId},
enterprise_sync_time = #{enterpriseSyncTime},
shareholder1 = #{shareholder1},
shareholder2 = #{shareholder2},
shareholder3 = #{shareholder3},
shareholder4 = #{shareholder4},
shareholder5 = #{shareholder5},
data_source = #{dataSource},
updated_by = #{updatedBy},
update_time = now()
where social_credit_code = #{socialCreditCode}
and trim(enterprise_name) = trim(#{enterpriseName})
</update>
<select id="selectEnterpriseProfileCandidates" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo">
select social_credit_code, enterprise_name, cache_info_id, enterprise_sync_time
from ccdi_enterprise_base_info
where enterprise_name is not null and trim(enterprise_name) != ''
order by enterprise_name, social_credit_code
</select>
</mapper>

View File

@@ -0,0 +1,31 @@
<?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.info.collection.mapper.CcdiEnterpriseInfoQueryCacheMapper">
<select id="selectValidCache" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache">
select info_id, query_param, query_result, query_type, created_date, valid_date
from ccdi_enterpriseinfo_query_cache
where query_param = #{queryParam}
and query_type = #{queryType}
and valid_date &gt; #{now}
limit 1
</select>
<select id="selectCache" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache">
select info_id, query_param, query_result, query_type, created_date, valid_date
from ccdi_enterpriseinfo_query_cache
where query_param = #{queryParam}
and query_type = #{queryType}
limit 1
</select>
<insert id="upsertCache" parameterType="com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache">
insert into ccdi_enterpriseinfo_query_cache
(query_param, query_result, query_type, created_date, valid_date)
values
(#{queryParam}, #{queryResult}, #{queryType}, #{createdDate}, #{validDate})
on duplicate key update
query_result = values(query_result),
created_date = values(created_date),
valid_date = values(valid_date)
</insert>
</mapper>

View File

@@ -0,0 +1,29 @@
<?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.info.collection.mapper.CcdiEnterpriseShareholderMapper">
<select id="selectByCreditCode" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseShareholder">
select shareholder_id, social_credit_code, shareholder_seq, shareholder_name,
shareholder_type, shareholder_credit_code, stock_percent, subscribed_capital,
capital_unit, cache_info_id, sync_time, create_by, create_time, update_by, update_time
from ccdi_enterprise_shareholder
where social_credit_code = #{creditCode}
order by shareholder_seq
</select>
<delete id="deleteByCreditCode">
delete from ccdi_enterprise_shareholder where social_credit_code = #{creditCode}
</delete>
<insert id="insertBatch">
insert into ccdi_enterprise_shareholder (
social_credit_code, shareholder_seq, shareholder_name, shareholder_type,
shareholder_credit_code, stock_percent, subscribed_capital, capital_unit,
cache_info_id, sync_time, create_by, create_time, update_by, update_time
) values
<foreach collection="list" item="item" separator=",">
(#{item.socialCreditCode}, #{item.shareholderSeq}, #{item.shareholderName}, #{item.shareholderType},
#{item.shareholderCreditCode}, #{item.stockPercent}, #{item.subscribedCapital}, #{item.capitalUnit},
#{item.cacheInfoId}, #{item.syncTime}, #{item.createBy}, #{item.createTime}, #{item.updateBy}, #{item.updateTime})
</foreach>
</insert>
</mapper>

View File

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

View File

@@ -6,9 +6,11 @@ import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO;
import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseShareholderMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.impl.CcdiEnterpriseBaseInfoServiceImpl;
import com.ruoyi.lsfx.domain.CallerContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -49,13 +51,22 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private CcdiEnterpriseShareholderMapper enterpriseShareholderMapper;
@Mock
private IEnterpriseProfileQueryService enterpriseProfileQueryService;
@Mock
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Test
void insertEnterpriseBaseInfo_shouldPersistWhenSocialCreditCodeIsUnique() {
CcdiEnterpriseBaseInfoAddDTO addDTO = buildAddDto();
when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null);
when(enterpriseBaseInfoMapper.insert(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1);
int result = service.insertEnterpriseBaseInfo(addDTO);
int result = service.insertEnterpriseBaseInfo(CallerContext.system(), addDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiEnterpriseBaseInfo> captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class);
@@ -72,7 +83,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null);
when(enterpriseBaseInfoMapper.insert(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1);
int result = service.insertEnterpriseBaseInfo(addDTO);
int result = service.insertEnterpriseBaseInfo(CallerContext.system(), addDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiEnterpriseBaseInfo> captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class);
@@ -88,7 +99,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.insertEnterpriseBaseInfo(addDTO));
() -> service.insertEnterpriseBaseInfo(CallerContext.system(), addDTO));
assertEquals("风险等级不在允许范围内", exception.getMessage());
}
@@ -99,7 +110,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode())).thenReturn(null);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.updateEnterpriseBaseInfo(editDTO));
() -> service.updateEnterpriseBaseInfo(CallerContext.system(), editDTO));
assertEquals("实体库记录不存在", exception.getMessage());
}
@@ -114,7 +125,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode())).thenReturn(existing);
when(enterpriseBaseInfoMapper.updateById(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1);
int result = service.updateEnterpriseBaseInfo(editDTO);
int result = service.updateEnterpriseBaseInfo(CallerContext.system(), editDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiEnterpriseBaseInfo> captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class);
@@ -130,6 +141,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
entity.setRiskLevel("1");
entity.setEntSource("GENERAL");
when(enterpriseBaseInfoMapper.selectById("91310000123456789A")).thenReturn(entity);
when(enterpriseShareholderMapper.selectByCreditCode("91310000123456789A")).thenReturn(java.util.List.of());
CcdiEnterpriseBaseInfoVO vo = service.selectEnterpriseBaseInfoById("91310000123456789A");

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
package com.ruoyi.lsfx.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.lsfx.domain.CallerContext;
import com.ruoyi.lsfx.domain.response.XinhuaEnterpriseQueryResult;
import com.ruoyi.lsfx.exception.LsfxApiException;
import com.ruoyi.lsfx.util.HttpUtil;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.Map;
/** 新华社工商信息同步接口客户端。 */
@Component
public class XinhuaEnterpriseClient {
private static final int PLATFORM_SUCCESS_CODE = 10000;
private static final int BUSINESS_SUCCESS_STATUS = 1;
private static final int BUSINESS_SUCCESS_REASON_CODE = 200;
@Resource
private HttpUtil httpUtil;
@Resource
private ObjectMapper objectMapper;
@Value("${xinhua-enterprise.api.url}")
private String apiUrl;
@Value("${xinhua-enterprise.api.org-code:999000}")
private String orgCode;
@Value("${xinhua-enterprise.api.run-type:1}")
private String runType;
public XinhuaEnterpriseQueryResult query(CallerContext caller, String enterpriseName) {
String normalizedName = normalize(enterpriseName);
if (!StringUtils.hasText(normalizedName)) {
throw new IllegalArgumentException("企业名称不能为空");
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("entName", normalizedName);
params.put("serialNum", buildSerialNum());
params.put("runType", runType);
params.put("orgCode", orgCode);
String rawJson = httpUtil.postUrlEncodedFormForString(caller, apiUrl, params, null);
JsonNode root = parse(rawJson);
JsonNode data = requireSuccess(root);
JsonNode profile = data.path("mappingOutputFields");
if (!profile.isObject() || profile.isEmpty()) {
throw new LsfxApiException("新华社工商接口返回工商信息为空");
}
String responseName = normalize(profile.path("enterpriseName").asText(null));
if (!normalizedName.equals(responseName)) {
throw new LsfxApiException("新华社工商接口返回企业名称与查询名称不一致");
}
if (!StringUtils.hasText(normalize(profile.path("creditCode").asText(null)))) {
throw new LsfxApiException("新华社工商接口未返回统一社会信用代码");
}
return new XinhuaEnterpriseQueryResult(rawJson, profile);
}
private JsonNode parse(String rawJson) {
try {
return objectMapper.readTree(rawJson);
} catch (Exception e) {
throw new LsfxApiException("新华社工商接口响应不是合法JSON", e);
}
}
private JsonNode requireSuccess(JsonNode root) {
if (!root.path("success").asBoolean(false)) {
throw new LsfxApiException(message(root, "新华社工商接口平台调用失败"));
}
if (root.path("code").asInt(Integer.MIN_VALUE) != PLATFORM_SUCCESS_CODE) {
throw new LsfxApiException("新华社工商接口平台状态码异常: " + root.path("code").asText());
}
JsonNode data = root.path("data");
if (!data.isObject()) {
throw new LsfxApiException("新华社工商接口返回data为空");
}
if (data.path("status").asInt(Integer.MIN_VALUE) != BUSINESS_SUCCESS_STATUS) {
throw new LsfxApiException(message(root, "新华社工商接口业务状态异常"));
}
if (data.path("reasonCode").asInt(Integer.MIN_VALUE) != BUSINESS_SUCCESS_REASON_CODE) {
throw new LsfxApiException(message(root, "新华社工商接口业务原因码异常"));
}
return data;
}
private String message(JsonNode root, String defaultMessage) {
String reason = normalize(root.path("data").path("reasonMessage").asText(null));
return StringUtils.hasText(reason) ? reason : defaultMessage;
}
private String buildSerialNum() {
return "CCDI_GS_" + System.currentTimeMillis() + "_" + IdUtils.fastSimpleUUID();
}
private String normalize(String value) {
return value == null ? null : value.trim();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.ruoyi.lsfx.domain.response;
import com.fasterxml.jackson.databind.JsonNode;
/** 新华社工商信息同步查询结果。 */
public record XinhuaEnterpriseQueryResult(String rawJson, JsonNode profile) {
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,13 @@
<version>${ruoyi.version}</version>
</dependency>
<!-- 企业工商信息统一查询能力 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ccdi-info-collection</artifactId>
<version>${ruoyi.version}</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>

View File

@@ -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());
}
/**
* 查询项目级筛选项
*/

View File

@@ -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);
}
@@ -187,8 +186,8 @@ public class CcdiFileUploadController extends BaseController {
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult deleteFile(@PathVariable Long id) {
projectAccessService.assertCanOperateByFileRecordId(id);
Long userId = SecurityUtils.getUserId();
String message = fileUploadService.deleteFileUploadRecord(id, userId);
CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser());
String message = fileUploadService.deleteFileUploadRecord(id, caller);
return AjaxResult.success(message);
}
}

View File

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

View File

@@ -0,0 +1,56 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.ProjectCounterpartyRefreshDTO;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
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;
import jakarta.validation.Valid;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/ccdi/project/counterparty-enterprise")
@Tag(name = "项目对手方工商信息")
public class CcdiProjectCounterpartyEnterpriseController {
@Resource
private IProjectCounterpartyEnterpriseService service;
@GetMapping("/detail")
@Operation(summary = "查询本地项目对手方工商详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult detail(@RequestParam Long projectId, @RequestParam String counterpartyName) {
return AjaxResult.success(service.getDetail(projectId, counterpartyName));
}
@PostMapping("/refresh")
@Operation(summary = "重新查询单个项目对手方工商信息")
@PreAuthorize("@ss.hasPermi('ccdi:project:counterpartyEnterprise:refresh')")
public AjaxResult refresh(@Valid @RequestBody ProjectCounterpartyRefreshDTO dto) {
return AjaxResult.success(service.refresh(CallerContext.from(SecurityUtils.getLoginUser()),
dto.getProjectId(), dto.getCounterpartyName()));
}
@PostMapping("/backfill")
@Operation(summary = "启动项目对手方工商信息历史补全")
@PreAuthorize("@ss.hasPermi('ccdi:project:counterpartyEnterprise:backfill')")
public AjaxResult backfill() {
return AjaxResult.success("任务已提交", service.startBackfill(CallerContext.from(SecurityUtils.getLoginUser())));
}
@GetMapping("/backfill/{taskId}")
@Operation(summary = "查询项目对手方工商信息补全状态")
@PreAuthorize("@ss.hasPermi('ccdi:project:counterpartyEnterprise:backfill')")
public AjaxResult backfillStatus(@PathVariable String taskId) {
return AjaxResult.success(service.getBackfillStatus(taskId));
}
}

View File

@@ -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, "未提交账户流水");
}
/**
* 导出风险明细
*/

View File

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

View File

@@ -0,0 +1,14 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ProjectCounterpartyRefreshDTO {
@NotNull(message = "项目ID不能为空")
private Long projectId;
@NotBlank(message = "对手方名称不能为空")
private String counterpartyName;
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
/** 项目对手方工商信息。 */
@Data
@TableName("ccdi_project_counterparty_enterprise")
public class CcdiProjectCounterpartyEnterprise {
@TableId(type = IdType.AUTO)
private Long counterpartyEnterpriseId;
private Long projectId;
private String counterpartyName;
private String socialCreditCode;
private String enterpriseName;
private BigDecimal registeredCapital;
private String registeredCapitalUnit;
private LocalDate registerDate;
private LocalDate establishDate;
private String industryCode;
private String industryName;
private String organizationTypeCode;
private String organizationTypeName;
private String regionCode;
private String regionName;
private String registerAddress;
private Integer employeeCount;
private String legalRepresentative;
private Long cacheInfoId;
private Date syncTime;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
}

View File

@@ -0,0 +1,26 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 项目对手方股东信息。 */
@Data
@TableName("ccdi_project_counterparty_shareholder")
public class CcdiProjectCounterpartyShareholder {
@TableId(type = IdType.AUTO)
private Long shareholderId;
private Long counterpartyEnterpriseId;
@TableField("shareholder_seq")
private Integer sequenceNo;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
private Date createTime;
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.domain.event;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.lsfx.domain.CallerContext;
/**
* 历史项目导入提交事件
@@ -10,14 +11,14 @@ public class CcdiProjectHistoryImportSubmittedEvent {
private final Long targetProjectId;
private final Integer targetLsfxProjectId;
private final CcdiProjectImportHistoryDTO dto;
private final String operator;
private final CallerContext caller;
public CcdiProjectHistoryImportSubmittedEvent(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator) {
CcdiProjectImportHistoryDTO dto, CallerContext caller) {
this.targetProjectId = targetProjectId;
this.targetLsfxProjectId = targetLsfxProjectId;
this.dto = dto;
this.operator = operator;
this.caller = caller;
}
public Long getTargetProjectId() {
@@ -32,7 +33,7 @@ public class CcdiProjectHistoryImportSubmittedEvent {
return dto;
}
public String getOperator() {
return operator;
public CallerContext getCaller() {
return caller;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Data
public class ProjectCounterpartyEnterpriseVO {
private Long projectId;
private String counterpartyName;
private String status;
private Boolean canRefresh;
private Date cacheValidDate;
private String socialCreditCode;
private String enterpriseName;
private BigDecimal registeredCapital;
private String registeredCapitalUnit;
private LocalDate registerDate;
private LocalDate establishDate;
private String industryCode;
private String industryName;
private String organizationTypeCode;
private String organizationTypeName;
private String regionCode;
private String regionName;
private String registerAddress;
private Integer employeeCount;
private String legalRepresentative;
private Date syncTime;
private List<ProjectCounterpartyShareholderVO> shareholders = new ArrayList<>();
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ProjectCounterpartyShareholderVO {
private Integer sequenceNo;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
@Data
public class ProjectCounterpartySyncTaskVO {
private String taskId;
private String status;
private Integer totalCount;
private Integer completedCount;
private Integer successCount;
private Integer skippedCount;
private Integer failureCount;
private String message;
}

View File

@@ -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);
@@ -45,4 +49,8 @@ public interface CcdiBankStatementMapper extends BaseMapper<CcdiBankStatement> {
CcdiBankStatementFilterOptionsVO selectFilterOptions(@Param("projectId") Long projectId);
Integer countMatchedStaffCountByProjectId(@Param("projectId") Long projectId);
List<String> selectDistinctCounterpartyNames(@Param("projectId") Long projectId);
List<Long> selectDistinctProjectIdsWithCounterparties();
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface CcdiProjectCounterpartyEnterpriseMapper extends BaseMapper<CcdiProjectCounterpartyEnterprise> {
CcdiProjectCounterpartyEnterprise selectByProjectAndName(@Param("projectId") Long projectId,
@Param("counterpartyName") String counterpartyName);
List<CcdiProjectCounterpartyEnterprise> selectByProjectId(@Param("projectId") Long projectId);
int deleteByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyShareholder;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface CcdiProjectCounterpartyShareholderMapper extends BaseMapper<CcdiProjectCounterpartyShareholder> {
List<CcdiProjectCounterpartyShareholder> selectByEnterpriseId(@Param("counterpartyEnterpriseId") Long counterpartyEnterpriseId);
int deleteByEnterpriseId(@Param("counterpartyEnterpriseId") Long counterpartyEnterpriseId);
int deleteByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -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
);
/**
* 查询项目员工负面征信导出列表
*

View File

@@ -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);
/**
* 查询导出列表
*

View File

@@ -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,17 +52,16 @@ public interface ICcdiFileUploadService {
String dataChannelCode,
String startDate,
String endDate,
Long userId,
String username);
CallerContext caller);
/**
* 删除上传记录并清理关联数据
*
* @param id 上传记录ID
* @param operatorUserId 当前操作用户ID
* @param caller 当前操作人上下文
* @return 删除结果
*/
String deleteFileUploadRecord(Long id, Long operatorUserId);
String deleteFileUploadRecord(Long id, CallerContext caller);
/**
* 查询上传记录列表

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.lsfx.domain.CallerContext;
/**
* 历史项目导入服务
@@ -15,8 +16,8 @@ public interface ICcdiProjectHistoryImportService {
* @param targetProjectId 目标项目ID
* @param targetLsfxProjectId 目标流水分析项目ID
* @param dto 导入参数
* @param operator 操作人
* @param caller 原始操作人上下文
*/
void submitImport(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator);
CcdiProjectImportHistoryDTO dto, CallerContext caller);
}

View File

@@ -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();
}
/**
* 重算结果总览员工结果并同步项目风险人数
*

View File

@@ -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);
/**
* 查询各状态的项目总数(不受搜索条件影响)

View File

@@ -0,0 +1,14 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartyEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartySyncTaskVO;
import com.ruoyi.lsfx.domain.CallerContext;
public interface IProjectCounterpartyEnterpriseService {
ProjectCounterpartyEnterpriseVO getDetail(Long projectId, String counterpartyName);
ProjectCounterpartyEnterpriseVO refresh(CallerContext caller, Long projectId, String counterpartyName);
void submitReconcile(CallerContext caller, Long projectId);
String startBackfill(CallerContext caller);
ProjectCounterpartySyncTaskVO getBackfillStatus(String taskId);
void deleteProjectData(Long projectId);
}

View File

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

View File

@@ -16,9 +16,11 @@ import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
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;
@@ -105,6 +107,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Resource
private ICcdiProjectService projectService;
@Resource
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
/**
* 获取临时文件存储目录
*/
@@ -157,8 +162,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 +213,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 +227,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
));
}
});
@@ -243,7 +247,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
@Override
public String deleteFileUploadRecord(Long id, Long operatorUserId) {
@Transactional(rollbackFor = Exception.class)
public String deleteFileUploadRecord(Long id, CallerContext caller) {
CcdiFileUploadRecord record = recordMapper.selectById(id);
validateDeleteRecord(record);
@@ -252,7 +257,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
* DeleteFilesRequest request = new DeleteFilesRequest();
* request.setGroupId(record.getLsfxProjectId());
* request.setLogIds(new Integer[]{record.getLogId()});
* request.setUserId(toUploadUserId(operatorUserId));
* request.setUserId(toUploadUserId(caller.userId()));
*
* DeleteFilesResponse response = lsfxClient.deleteFiles(request);
* if (response == null || Boolean.FALSE.equals(response.getSuccessResponse())) {
@@ -272,9 +277,23 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
bankTagService.submitAutoRebuild(record.getProjectId(), TriggerType.AUTO_FILE_DELETE);
submitCounterpartyReconcileAfterCommit(caller, record.getProjectId());
return "删除成功,已开始项目重新打标";
}
private void submitCounterpartyReconcileAfterCommit(CallerContext caller, Long projectId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
}
});
}
@Override
public Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
CcdiFileUploadQueryDTO queryDTO) {
@@ -345,9 +364,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 +425,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 +457,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 +515,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 +539,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);
@@ -553,6 +573,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
.whenComplete((unused, throwable) -> {
boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE)));
handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_BATCH_UPLOAD, anySuccess);
if (anySuccess) {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
}
});
log.info("【文件上传】调度线程完成: projectId={}, batchId={}", projectId, batchId);
@@ -600,7 +623,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 +643,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);
@@ -651,6 +676,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
.whenComplete((unused, throwable) -> {
boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE)));
handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_PULL_BANK_INFO, anySuccess);
if (anySuccess) {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
}
});
}
@@ -660,7 +688,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,18 +706,33 @@ 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");
}
Integer logId = response.getData().get(0);
if (logId == null) {
List<Integer> logIds = response.getData().stream()
.filter(Objects::nonNull)
.toList();
if (logIds.isEmpty()) {
throw new RuntimeException("拉取本行信息失败: 未返回logId");
}
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
return true;
boolean anySuccess = false;
for (int i = 0; i < logIds.size(); i++) {
Integer logId = logIds.get(i);
CcdiFileUploadRecord currentRecord = i == 0
? record
: createAdditionalPullBankInfoRecord(record, idCard);
try {
anySuccess |= processRecordAfterLogIdReady(projectId, lsfxProjectId, currentRecord, logId, caller);
} catch (Exception logException) {
log.error("【拉取本行信息】处理logId失败: idCard={}, logId={}, recordId={}",
idCard, logId, currentRecord.getId(), logException);
updateFailedRecord(currentRecord, logException.getMessage());
}
}
return anySuccess;
} catch (Exception e) {
log.error("【拉取本行信息】处理失败: idCard={}, recordId={}", idCard, record.getId(), e);
updateFailedRecord(record, e.getMessage());
@@ -696,6 +740,24 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
}
private CcdiFileUploadRecord createAdditionalPullBankInfoRecord(CcdiFileUploadRecord sourceRecord,
String idCard) {
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
record.setProjectId(sourceRecord.getProjectId());
record.setLsfxProjectId(sourceRecord.getLsfxProjectId());
record.setFileName(idCard);
record.setFileSize(0L);
record.setFileStatus("uploading");
record.setAccountNos(idCard);
record.setUploadTime(new Date());
record.setUploadUser(sourceRecord.getUploadUser());
recordMapper.insertBatch(List.of(record));
if (record.getId() == null) {
throw new RuntimeException("创建金综流水上传记录失败: 未生成记录ID");
}
return record;
}
/**
* 异步处理单个文件的完整流程
* 包含:上传 → 轮询解析状态 → 获取结果 → 保存流水数据
@@ -709,7 +771,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 +793,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 +807,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;
@@ -777,25 +840,27 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
bankTagService.submitAutoRebuild(projectId, triggerType);
}
private void processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId) {
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false);
private boolean processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
CallerContext caller) {
return processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false, caller);
}
private void processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
boolean preserveRecordFileName) {
private boolean processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
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 +870,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()) {
@@ -833,7 +898,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
if (!parseSuccess) {
log.warn("【文件上传】步骤6: 解析失败: status={}, desc={}", status, uploadStatusDesc);
updateFailedRecord(record, "解析失败: " + uploadStatusDesc);
return;
return false;
}
log.info("【文件上传】步骤6: 解析成功,保存主体信息");
@@ -846,11 +911,11 @@ 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;
return false;
}
record.setFileStatus("parsed_success");
@@ -858,6 +923,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
record.setAccountNos(accountNosStr);
record.setErrorMessage(null);
recordMapper.updateById(record);
return true;
}
/**
@@ -867,7 +933,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 +942,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 +985,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 +999,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 +1033,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);

View File

@@ -21,7 +21,7 @@ public class CcdiProjectHistoryImportEventListener {
event.getTargetProjectId(),
event.getTargetLsfxProjectId(),
event.getDto(),
event.getOperator()
event.getCaller()
);
}
}

View File

@@ -10,6 +10,8 @@ import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectHistoryImportService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -52,14 +54,17 @@ public class CcdiProjectHistoryImportServiceImpl implements ICcdiProjectHistoryI
@Resource
private ICcdiBankTagService bankTagService;
@Resource
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
@Override
public void submitImport(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator) {
fileUploadExecutor.execute(() -> executeImport(targetProjectId, targetLsfxProjectId, dto, operator));
CcdiProjectImportHistoryDTO dto, CallerContext caller) {
fileUploadExecutor.execute(() -> executeImport(targetProjectId, targetLsfxProjectId, dto, caller));
}
private void executeImport(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator) {
CcdiProjectImportHistoryDTO dto, CallerContext caller) {
List<CcdiFileUploadRecord> sourceRecords = recordMapper.selectSuccessfulRecordsByProjectIds(dto.getSourceProjectIds());
if (sourceRecords == null || sourceRecords.isEmpty()) {
log.info("【项目历史导入】无可复制的来源批次: projectId={}, sourceProjectIds={}",
@@ -91,7 +96,7 @@ public class CcdiProjectHistoryImportServiceImpl implements ICcdiProjectHistoryI
if (statementsToInsert.size() > sizeBefore) {
recordsToInsert.add(buildHistoryImportRecord(
sourceRecord, targetProjectId, targetLsfxProjectId, newBatchId, resolveSourceProjectName(sourceRecord.getProjectId()), operator
sourceRecord, targetProjectId, targetLsfxProjectId, newBatchId, resolveSourceProjectName(sourceRecord.getProjectId()), caller.username()
));
}
}
@@ -105,6 +110,7 @@ public class CcdiProjectHistoryImportServiceImpl implements ICcdiProjectHistoryI
if (!statementsToInsert.isEmpty()) {
refreshProjectTargetCount(targetProjectId);
bankTagService.submitAutoRebuild(targetProjectId, TriggerType.AUTO_BATCH_UPLOAD);
counterpartyEnterpriseService.submitReconcile(caller, targetProjectId);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,280 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyShareholder;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartyEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartyShareholderVO;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartySyncTaskVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectCounterpartyEnterpriseMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectCounterpartyShareholderMapper;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.info.collection.domain.model.EnterpriseShareholderProfile;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Service
public class ProjectCounterpartyEnterpriseServiceImpl implements IProjectCounterpartyEnterpriseService {
private static final String TASK_PREFIX = "project:counterparty:profile:backfill:";
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiProjectCounterpartyEnterpriseMapper enterpriseMapper;
@Resource
private CcdiProjectCounterpartyShareholderMapper shareholderMapper;
@Resource
private IEnterpriseProfileQueryService queryService;
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
@Qualifier("enterpriseProfileExecutor")
private Executor enterpriseProfileExecutor;
@Override
public ProjectCounterpartyEnterpriseVO getDetail(Long projectId, String counterpartyName) {
String name = normalize(counterpartyName);
requireCurrentCounterparty(projectId, name);
CcdiProjectCounterpartyEnterprise entity = enterpriseMapper.selectByProjectAndName(projectId, name);
CcdiEnterpriseInfoQueryCache cache = queryService.findCache(name);
Date now = new Date();
if (entity == null) {
ProjectCounterpartyEnterpriseVO vo = new ProjectCounterpartyEnterpriseVO();
vo.setProjectId(projectId);
vo.setCounterpartyName(name);
vo.setStatus("NOT_FOUND");
vo.setCanRefresh(true);
vo.setCacheValidDate(cache == null ? null : cache.getValidDate());
return vo;
}
ProjectCounterpartyEnterpriseVO vo = new ProjectCounterpartyEnterpriseVO();
BeanUtils.copyProperties(entity, vo);
vo.setStatus(cache != null && cache.getValidDate() != null && cache.getValidDate().after(now) ? "SUCCESS" : "EXPIRED");
vo.setCanRefresh(!"SUCCESS".equals(vo.getStatus()));
vo.setCacheValidDate(cache == null ? null : cache.getValidDate());
vo.setShareholders(shareholderMapper.selectByEnterpriseId(entity.getCounterpartyEnterpriseId()).stream()
.map(this::toShareholderVO).toList());
return vo;
}
@Override
public ProjectCounterpartyEnterpriseVO refresh(CallerContext caller, Long projectId, String counterpartyName) {
String name = normalize(counterpartyName);
requireCurrentCounterparty(projectId, name);
CcdiProjectCounterpartyEnterprise existing = enterpriseMapper.selectByProjectAndName(projectId, name);
CcdiEnterpriseInfoQueryCache cache = queryService.findCache(name);
boolean cacheValid = cache != null && cache.getValidDate() != null && cache.getValidDate().after(new Date());
if (existing != null && cacheValid) {
throw new IllegalStateException("工商缓存仍在有效期内,无需重新查询");
}
EnterpriseProfileQueryResult result = queryService.query(caller, name, !cacheValid);
upsert(projectId, name, result, caller.username());
return getDetail(projectId, name);
}
@Override
public void submitReconcile(CallerContext caller, Long projectId) {
CompletableFuture.runAsync(() -> reconcile(caller, projectId, null));
}
@Override
public String startBackfill(CallerContext caller) {
String taskId = UUID.randomUUID().toString().replace("-", "");
List<Long> projectIds = bankStatementMapper.selectDistinctProjectIdsWithCounterparties();
int total = projectIds.stream().mapToInt(id -> bankStatementMapper.selectDistinctCounterpartyNames(id).size()).sum();
initializeTask(taskId, total);
CompletableFuture.runAsync(() -> {
TaskCounters counters = new TaskCounters();
for (Long projectId : projectIds) {
reconcile(caller, projectId, new Progress(taskId, counters));
}
updateTask(taskId, "COMPLETED", counters);
});
return taskId;
}
@Override
public ProjectCounterpartySyncTaskVO getBackfillStatus(String taskId) {
Map<Object, Object> values = redisTemplate.opsForHash().entries(taskKey(taskId));
if (values.isEmpty()) {
throw new IllegalArgumentException("任务不存在或已过期");
}
ProjectCounterpartySyncTaskVO vo = new ProjectCounterpartySyncTaskVO();
vo.setTaskId(string(values.get("taskId")));
vo.setStatus(string(values.get("status")));
vo.setTotalCount(number(values.get("totalCount")));
vo.setCompletedCount(number(values.get("completedCount")));
vo.setSuccessCount(number(values.get("successCount")));
vo.setSkippedCount(number(values.get("skippedCount")));
vo.setFailureCount(number(values.get("failureCount")));
vo.setMessage(string(values.get("message")));
return vo;
}
@Override
public void deleteProjectData(Long projectId) {
shareholderMapper.deleteByProjectId(projectId);
enterpriseMapper.deleteByProjectId(projectId);
}
private void reconcile(CallerContext caller, Long projectId, Progress progress) {
List<String> names = bankStatementMapper.selectDistinctCounterpartyNames(projectId);
if (names.isEmpty()) {
transactionTemplate.executeWithoutResult(status -> deleteProjectData(projectId));
return;
}
for (int start = 0; start < names.size(); start += 100) {
List<String> batch = names.subList(start, Math.min(start + 100, names.size()));
List<CompletableFuture<Void>> futures = batch.stream().map(name -> CompletableFuture.runAsync(() -> {
try {
EnterpriseProfileQueryResult result = queryService.query(caller, name, false);
upsert(projectId, name, result, caller.username());
if (progress != null) progress.counters.success.incrementAndGet();
} catch (Exception e) {
if (progress != null) progress.counters.failure.incrementAndGet();
} finally {
if (progress != null) {
progress.counters.completed.incrementAndGet();
updateTask(progress.taskId, "PROCESSING", progress.counters);
}
}
}, enterpriseProfileExecutor)).toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
removeStale(projectId, new HashSet<>(names));
}
private void upsert(Long projectId, String name, EnterpriseProfileQueryResult result, String username) {
transactionTemplate.executeWithoutResult(status -> {
EnterpriseProfile profile = result.profile();
CcdiProjectCounterpartyEnterprise entity = enterpriseMapper.selectByProjectAndName(projectId, name);
Date now = new Date();
boolean insert = entity == null;
if (insert) {
entity = new CcdiProjectCounterpartyEnterprise();
entity.setProjectId(projectId);
entity.setCounterpartyName(name);
entity.setCreateBy(username);
entity.setCreateTime(now);
}
entity.setSocialCreditCode(profile.getCreditCode());
entity.setEnterpriseName(profile.getEnterpriseName());
entity.setRegisteredCapital(profile.getRegisteredCapital());
entity.setRegisteredCapitalUnit(profile.getRegisteredCapitalUnit());
entity.setRegisterDate(profile.getRegisterDate());
entity.setEstablishDate(profile.getEstablishDate());
entity.setIndustryCode(profile.getIndustryCode());
entity.setIndustryName(profile.getIndustryName());
entity.setOrganizationTypeCode(profile.getOrganizationTypeCode());
entity.setOrganizationTypeName(profile.getOrganizationTypeName());
entity.setRegionCode(profile.getRegionCode());
entity.setRegionName(profile.getRegionName());
entity.setRegisterAddress(profile.getRegisterAddress());
entity.setEmployeeCount(profile.getEmployeeCount());
entity.setLegalRepresentative(profile.getLegalRepresentative());
entity.setCacheInfoId(result.cacheInfoId());
entity.setSyncTime(now);
entity.setUpdateBy(username);
entity.setUpdateTime(now);
if (insert) enterpriseMapper.insert(entity); else enterpriseMapper.updateById(entity);
shareholderMapper.deleteByEnterpriseId(entity.getCounterpartyEnterpriseId());
for (EnterpriseShareholderProfile item : profile.getShareholders()) {
CcdiProjectCounterpartyShareholder shareholder = new CcdiProjectCounterpartyShareholder();
shareholder.setCounterpartyEnterpriseId(entity.getCounterpartyEnterpriseId());
shareholder.setSequenceNo(item.getSequence());
shareholder.setShareholderName(item.getShareholderName());
shareholder.setStockPercent(item.getStockPercent());
shareholder.setSubscribedCapital(item.getSubscribedCapital());
shareholder.setCapitalUnit(item.getCapitalUnit());
shareholder.setCreateTime(now);
shareholderMapper.insert(shareholder);
}
});
}
private void removeStale(Long projectId, Set<String> currentNames) {
transactionTemplate.executeWithoutResult(status -> enterpriseMapper.selectByProjectId(projectId).stream()
.filter(item -> !currentNames.contains(item.getCounterpartyName()))
.forEach(item -> {
shareholderMapper.deleteByEnterpriseId(item.getCounterpartyEnterpriseId());
enterpriseMapper.deleteById(item.getCounterpartyEnterpriseId());
}));
}
private void requireCurrentCounterparty(Long projectId, String name) {
if (projectId == null || !StringUtils.hasText(name)
|| !bankStatementMapper.selectDistinctCounterpartyNames(projectId).contains(name)) {
throw new IllegalArgumentException("该对手方不在项目当前流水中");
}
}
private ProjectCounterpartyShareholderVO toShareholderVO(CcdiProjectCounterpartyShareholder entity) {
ProjectCounterpartyShareholderVO vo = new ProjectCounterpartyShareholderVO();
BeanUtils.copyProperties(entity, vo);
return vo;
}
private String normalize(String value) { return value == null ? null : value.trim(); }
private String taskKey(String taskId) { return TASK_PREFIX + taskId; }
private String string(Object value) { return value == null ? null : value.toString(); }
private Integer number(Object value) { return value == null ? 0 : Integer.valueOf(value.toString()); }
private void initializeTask(String taskId, int total) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("taskId", taskId);
values.put("status", "PROCESSING");
values.put("totalCount", total);
values.put("completedCount", 0);
values.put("successCount", 0);
values.put("skippedCount", 0);
values.put("failureCount", 0);
values.put("message", "正在补全项目对手方工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
redisTemplate.expire(taskKey(taskId), 7, TimeUnit.DAYS);
}
private void updateTask(String taskId, String status, TaskCounters counters) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("status", status);
values.put("completedCount", counters.completed.get());
values.put("successCount", counters.success.get());
values.put("skippedCount", counters.skipped.get());
values.put("failureCount", counters.failure.get());
values.put("message", "COMPLETED".equals(status) ? "项目对手方工商信息补全完成" : "正在补全项目对手方工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
}
private static class TaskCounters {
private final AtomicInteger completed = new AtomicInteger();
private final AtomicInteger success = new AtomicInteger();
private final AtomicInteger skipped = new AtomicInteger();
private final AtomicInteger failure = new AtomicInteger();
}
private record Progress(String taskId, TaskCounters counters) { }
}

View File

@@ -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"/>
@@ -125,6 +139,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and trim(bs.cret_no) != ''
</select>
<select id="selectDistinctCounterpartyNames" resultType="java.lang.String">
select distinct trim(CUSTOMER_ACCOUNT_NAME)
from ccdi_bank_statement
where project_id = #{projectId}
and CUSTOMER_ACCOUNT_NAME is not null
and trim(CUSTOMER_ACCOUNT_NAME) != ''
order by trim(CUSTOMER_ACCOUNT_NAME)
</select>
<select id="selectDistinctProjectIdsWithCounterparties" resultType="java.lang.Long">
select distinct project_id
from ccdi_bank_statement
where project_id is not null
and CUSTOMER_ACCOUNT_NAME is not null
and trim(CUSTOMER_ACCOUNT_NAME) != ''
order by project_id
</select>
<sql id="parsedTrxDateExpr">
CASE
WHEN bs.TRX_DATE IS NULL OR TRIM(bs.TRX_DATE) = '' THEN NULL
@@ -284,6 +316,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 +365,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"/>

View File

@@ -0,0 +1,17 @@
<?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.ccdi.project.mapper.CcdiProjectCounterpartyEnterpriseMapper">
<select id="selectByProjectAndName" resultType="com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise">
select * from ccdi_project_counterparty_enterprise
where project_id = #{projectId} and counterparty_name = #{counterpartyName}
limit 1
</select>
<select id="selectByProjectId" resultType="com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise">
select * from ccdi_project_counterparty_enterprise where project_id = #{projectId}
</select>
<delete id="deleteByProjectId">
delete from ccdi_project_counterparty_enterprise where project_id = #{projectId}
</delete>
</mapper>

View File

@@ -0,0 +1,21 @@
<?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.ccdi.project.mapper.CcdiProjectCounterpartyShareholderMapper">
<select id="selectByEnterpriseId" resultType="com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyShareholder">
select * from ccdi_project_counterparty_shareholder
where counterparty_enterprise_id = #{counterpartyEnterpriseId}
order by shareholder_seq, shareholder_id
</select>
<delete id="deleteByEnterpriseId">
delete from ccdi_project_counterparty_shareholder
where counterparty_enterprise_id = #{counterpartyEnterpriseId}
</delete>
<delete id="deleteByProjectId">
delete s from ccdi_project_counterparty_shareholder s
inner join ccdi_project_counterparty_enterprise e
on e.counterparty_enterprise_id = s.counterparty_enterprise_id
where e.project_id = #{projectId}
</delete>
</mapper>

View File

@@ -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) &lt; 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) &gt; 0
then round(agg.totalAmountCr / subject_total.subjectAmountCr, 4)
else 0
end as inflowRatio,
case
when ifnull(subject_total.subjectAmountDr, 0) &gt; 0
then round(agg.totalAmountDr / subject_total.subjectAmountDr, 4)
else 0
end as outflowRatio,
case
when ifnull(subject_total.subjectAmountCr, 0) &gt; 0
and agg.totalAmountCr / subject_total.subjectAmountCr &gt;= 0.1
then 1
when ifnull(subject_total.subjectAmountDr, 0) &gt; 0
and agg.totalAmountDr / subject_total.subjectAmountDr &gt;= 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

View File

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

View File

@@ -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);
@@ -131,16 +134,16 @@ class CcdiFileUploadControllerTest {
}
@Test
void deleteFile_shouldUseCurrentLoginUserId() {
void deleteFile_shouldUseCurrentLoginUser() {
setLoginUser(9527L, "admin");
when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
when(fileUploadService.deleteFileUploadRecord(123L, CALLER))
.thenReturn("删除成功");
AjaxResult result = controller.deleteFile(123L);
assertEquals(200, result.get("code"));
assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
verify(fileUploadService).deleteFileUploadRecord(123L, CALLER);
}
private void setLoginUser(Long userId, String username) {

View File

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

View File

@@ -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) &lt; count(*)"), aggregateSql);
}
private String extractSelect(String xml, String selectId) {
String start = "<select id=\"" + selectId + "\"";
int startIndex = xml.indexOf(start);

View File

@@ -14,9 +14,11 @@ import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
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 +36,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 +66,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 +75,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
@@ -97,6 +102,9 @@ class CcdiFileUploadServiceImplTest {
@Mock
private ICcdiProjectService projectService;
@Mock
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
@TempDir
Path tempDir;
@@ -149,8 +157,7 @@ class CcdiFileUploadServiceImplTest {
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-03-01",
"2026-03-10",
9527L,
"admin"
CALLER
);
assertNotNull(batchId);
@@ -177,8 +184,7 @@ class CcdiFileUploadServiceImplTest {
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-01-01",
"2026-01-31",
1L,
"tester"
CALLER
));
}
@@ -195,7 +201,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 +228,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 +257,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 +278,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 +299,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 +381,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 +408,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 +422,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 +435,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 +456,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 +473,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 +487,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 +503,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())
@@ -484,22 +515,81 @@ class CcdiFileUploadServiceImplTest {
}
@Test
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
void processPullBankInfoAsync_shouldProcessAllJzlLogIds() {
Integer secondLogId = LOG_ID + 1;
Integer thirdLogId = LOG_ID + 2;
List<CcdiFileUploadRecord> insertedAdditionalRecords = new ArrayList<>();
doAnswer(invocation -> {
List<CcdiFileUploadRecord> records = invocation.getArgument(0);
for (int i = 0; i < records.size(); i++) {
records.get(i).setId(RECORD_ID + i + 1);
CcdiFileUploadRecord snapshot = new CcdiFileUploadRecord();
snapshot.setId(records.get(i).getId());
snapshot.setFileName(records.get(i).getFileName());
snapshot.setAccountNos(records.get(i).getAccountNos());
snapshot.setUploadUser(records.get(i).getUploadUser());
snapshot.setFileStatus(records.get(i).getFileStatus());
insertedAdditionalRecords.add(snapshot);
}
return records.size();
}).when(recordMapper).insertBatch(any());
when(lsfxClient.fetchInnerFlow(eq(CALLER), any()))
.thenReturn(buildFetchInnerFlowResponse(LOG_ID, secondLogId, thirdLogId));
when(lsfxClient.checkParseStatus(eq(CALLER), eq(LSFX_PROJECT_ID), org.mockito.ArgumentMatchers.anyString()))
.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.setUploadUser("admin");
boolean success = service.processPullBankInfoAsync(
PROJECT_ID,
LSFX_PROJECT_ID,
record,
"110101199001018888",
LsfxConstants.DATA_CHANNEL_JZL,
null,
null,
CALLER
);
assertTrue(success);
assertEquals(2, insertedAdditionalRecords.size());
assertEquals("110101199001018888", insertedAdditionalRecords.get(0).getFileName());
assertEquals("110101199001018888", insertedAdditionalRecords.get(0).getAccountNos());
assertEquals("admin", insertedAdditionalRecords.get(0).getUploadUser());
verify(lsfxClient).checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID));
verify(lsfxClient).checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(secondLogId));
verify(lsfxClient).checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(thirdLogId));
verify(lsfxClient).getBankStatement(eq(CALLER), org.mockito.ArgumentMatchers.<GetBankStatementRequest>argThat(request ->
LOG_ID.equals(request.getLogId())));
verify(lsfxClient).getBankStatement(eq(CALLER), org.mockito.ArgumentMatchers.<GetBankStatementRequest>argThat(request ->
secondLogId.equals(request.getLogId())));
verify(lsfxClient).getBankStatement(eq(CALLER), org.mockito.ArgumentMatchers.<GetBankStatementRequest>argThat(request ->
thirdLogId.equals(request.getLogId())));
}
@Test
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
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 +607,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 +626,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 +636,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 +665,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 ->
@@ -607,15 +699,16 @@ class CcdiFileUploadServiceImplTest {
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
String result = service.deleteFileUploadRecord(RECORD_ID, CALLER);
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())
));
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
verify(counterpartyEnterpriseService).submitReconcile(CALLER, PROJECT_ID);
verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(2).equals(item.getTargetCount())
));
@@ -628,7 +721,7 @@ class CcdiFileUploadServiceImplTest {
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
() -> service.deleteFileUploadRecord(RECORD_ID, CALLER));
assertTrue(exception.getMessage().contains("仅支持删除解析成功文件"));
}
@@ -641,10 +734,10 @@ class CcdiFileUploadServiceImplTest {
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
() -> service.deleteFileUploadRecord(RECORD_ID, CALLER));
assertTrue(exception.getMessage().contains("历史导入文件不支持删除"));
verify(lsfxClient, never()).deleteFiles(any());
verify(lsfxClient, never()).deleteFiles(any(), any());
}
@Test
@@ -656,10 +749,10 @@ class CcdiFileUploadServiceImplTest {
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
String result = service.deleteFileUploadRecord(RECORD_ID, CALLER);
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 +762,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 +785,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 +809,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 +831,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 +845,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 +866,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 +880,7 @@ class CcdiFileUploadServiceImplTest {
Object result = ReflectionTestUtils.invokeMethod(
service,
"fetchAndSaveBankStatements",
CALLER,
PROJECT_ID,
LSFX_PROJECT_ID,
LOG_ID,
@@ -825,12 +919,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 +933,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")));
@@ -965,9 +1059,9 @@ class CcdiFileUploadServiceImplTest {
return response;
}
private FetchInnerFlowResponse buildFetchInnerFlowResponse(Integer logId) {
private FetchInnerFlowResponse buildFetchInnerFlowResponse(Integer... logIds) {
FetchInnerFlowResponse response = new FetchInnerFlowResponse();
response.setData(List.of(logId));
response.setData(List.of(logIds));
return response;
}
@@ -1026,10 +1120,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 {

View File

@@ -9,6 +9,8 @@ import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.lsfx.domain.CallerContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -34,6 +36,8 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiProjectHistoryImportServiceImplTest {
private static final CallerContext CALLER = CallerContext.of(9527L, "tester");
@InjectMocks
private CcdiProjectHistoryImportServiceImpl service;
@@ -52,6 +56,9 @@ class CcdiProjectHistoryImportServiceImplTest {
@Mock
private ICcdiBankTagService bankTagService;
@Mock
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
@Test
void shouldFilterStatementsByTrxDateAndDeduplicateAcrossSourceProjects() {
CcdiProjectImportHistoryDTO dto = buildImportDto();
@@ -80,7 +87,7 @@ class CcdiProjectHistoryImportServiceImplTest {
when(projectMapper.selectById(11L)).thenReturn(buildProject(11L, "历史项目A"));
when(projectMapper.selectById(12L)).thenReturn(buildProject(12L, "历史项目B"));
service.submitImport(90L, 3001, dto, "tester");
service.submitImport(90L, 3001, dto, CALLER);
assertEquals(2, insertedStatements.get().size());
assertTrue(insertedStatements.get().stream().allMatch(item -> Long.valueOf(90L).equals(item.getProjectId())));
@@ -120,7 +127,7 @@ class CcdiProjectHistoryImportServiceImplTest {
when(projectMapper.selectById(11L)).thenReturn(buildProject(11L, "历史项目A"));
service.submitImport(90L, 3001, dto, "tester");
service.submitImport(90L, 3001, dto, CALLER);
assertEquals(1, insertedStatements.get().size());
assertNotEquals(101, insertedStatements.get().get(0).getBatchId());
@@ -148,7 +155,7 @@ class CcdiProjectHistoryImportServiceImplTest {
when(projectMapper.selectById(90L)).thenReturn(buildProject(90L, "新项目"));
when(bankStatementMapper.countMatchedStaffCountByProjectId(90L)).thenReturn(3);
service.submitImport(90L, 3001, dto, "tester");
service.submitImport(90L, 3001, dto, CALLER);
verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(project ->
Long.valueOf(90L).equals(project.getProjectId()) && Integer.valueOf(3).equals(project.getTargetCount())

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More