Compare commits

..

17 Commits

137 changed files with 17011 additions and 531 deletions

1
.gitignore vendored
View File

@@ -74,6 +74,7 @@ db_config.conf
# Local deployment bundles # Local deployment bundles
.deploy/ .deploy/
/ccdi_????????.zip
output/ output/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
14.21.3

View File

@@ -1,5 +1,44 @@
# AGENTS.md - AI Coding Assistant Guide # 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 脚本、实施文档与测试文档。 本仓库是纪检初核系统主仓库,基于若依 `v3.9.1`,当前技术栈以 `Java 21 + Spring Boot 3 + Vue 2` 为主,并包含独立的流水分析 Mock 服务、Docker 部署文件、SQL 脚本、实施文档与测试文档。
@@ -229,7 +268,7 @@ return AjaxResult.success(result);
- 导入功能测试必须进入真实业务页面执行,先在页面内下载当前导入模板,再基于该模板生成测试文件,禁止手工凭记忆新建表头或脱离页面直接构造上传文件 - 导入功能测试必须进入真实业务页面执行,先在页面内下载当前导入模板,再基于该模板生成测试文件,禁止手工凭记忆新建表头或脱离页面直接构造上传文件
- 双 Sheet 模板的导入测试必须覆盖两个 Sheet 的联动关系;除“缺少 Sheet / 空 Sheet”专项场景外默认两个 Sheet 都要准备测试数据 - 双 Sheet 模板的导入测试必须覆盖两个 Sheet 的联动关系;除“缺少 Sheet / 空 Sheet”专项场景外默认两个 Sheet 都要准备测试数据
- 导入测试文件优先放在 `output/spreadsheet/``output/playwright/`,不提交到 git - 导入测试文件优先放在 `output/spreadsheet/``output/browser-use/`,不提交到 git
- 需要按场景拆分测试文件,避免多个互斥校验互相覆盖;至少覆盖空模板、主信息必填、主信息格式与金额、主从关系异常、供应商校验、缺少/空 Sheet、成功导入、成功与失败混合、失败记录查看、导入后清理回滚 - 需要按场景拆分测试文件,避免多个互斥校验互相覆盖;至少覆盖空模板、主信息必填、主信息格式与金额、主从关系异常、供应商校验、缺少/空 Sheet、成功导入、成功与失败混合、失败记录查看、导入后清理回滚
- 主从关系异常测试至少覆盖:已存在主键、供应商有数据但主信息缺失、主信息重复、供应商 Sheet 中采购事项 ID 为空 - 主从关系异常测试至少覆盖:已存在主键、供应商有数据但主信息缺失、主信息重复、供应商 Sheet 中采购事项 ID 为空
- 供应商校验测试至少覆盖:重复供应商、多条中标、供应商名称为空、名称超长、联系人超长、银行账户超长、联系电话非法、统一信用代码非法、是否中标枚举非法 - 供应商校验测试至少覆盖:重复供应商、多条中标、供应商名称为空、名称超长、联系人超长、银行账户超长、联系电话非法、统一信用代码非法、是否中标枚举非法

92
build_release_ccdi.sh Executable file
View File

@@ -0,0 +1,92 @@
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
DATE_STAMP=$(date "+%Y%m%d")
RELEASE_ZIP="$ROOT_DIR/ccdi_${DATE_STAMP}.zip"
STAGE_DIR="$ROOT_DIR/.deploy/ccdi-release-package"
WORK_DIR="$STAGE_DIR/files"
BACKEND_JAR_SOURCE="$ROOT_DIR/ruoyi-admin/target/ruoyi-admin.jar"
FRONTEND_DIR="$ROOT_DIR/ruoyi-ui"
FRONTEND_DIST_DIR="$FRONTEND_DIR/dist"
FRONTEND_DIST_ZIP="$WORK_DIR/dist.zip"
log_info() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"
}
log_error() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
log_error "缺少命令: $1"
exit 1
fi
}
reset_stage_dir() {
rm -rf "$STAGE_DIR"
mkdir -p "$WORK_DIR"
}
build_backend() {
log_info "开始构建后端生产 jar"
(
cd "$ROOT_DIR"
mvn -pl ruoyi-admin -am clean package -DskipTests
)
if [ ! -f "$BACKEND_JAR_SOURCE" ]; then
log_error "未生成后端 jar: $BACKEND_JAR_SOURCE"
exit 1
fi
}
build_frontend() {
log_info "开始构建前端生产 dist"
FRONTEND_DIR="$FRONTEND_DIR" zsh -lic 'cd "$FRONTEND_DIR" && nvm use >/dev/null && npm run build:prod'
if [ ! -f "$FRONTEND_DIST_DIR/index.html" ]; then
log_error "前端生产构建失败,未找到: $FRONTEND_DIST_DIR/index.html"
exit 1
fi
(
cd "$FRONTEND_DIR"
zip -qr "$FRONTEND_DIST_ZIP" dist
)
if [ ! -f "$FRONTEND_DIST_ZIP" ]; then
log_error "未生成前端压缩包: $FRONTEND_DIST_ZIP"
exit 1
fi
}
package_release() {
cp "$BACKEND_JAR_SOURCE" "$WORK_DIR/ruoyi-admin.jar"
rm -f "$RELEASE_ZIP"
(
cd "$WORK_DIR"
zip -qr "$RELEASE_ZIP" ruoyi-admin.jar dist.zip
)
log_info "上线压缩包已生成: $RELEASE_ZIP"
log_info "压缩包根层内容: ruoyi-admin.jar, dist.zip"
}
main() {
require_command mvn
require_command zsh
require_command zip
reset_stage_dir
build_backend
build_frontend
package_release
}
main "$@"

View File

@@ -49,6 +49,12 @@
<artifactId>easyexcel</artifactId> <artifactId>easyexcel</artifactId>
</dependency> </dependency>
<!-- pdf导出工具 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<!-- 测试依赖 --> <!-- 测试依赖 -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@@ -0,0 +1,78 @@
package com.ruoyi.ccdi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.utils.SecurityUtils;
import java.util.List;
/**
* 资金流图谱Controller
*/
@RestController
@RequestMapping("/ccdi/project/fund-graph")
@Tag(name = "资金流图谱")
public class CcdiFundGraphController extends BaseController {
@Resource
private ICcdiFundGraphService fundGraphService;
@GetMapping("/search")
@Operation(summary = "查询资金流图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
List<CcdiFundGraphNodeVO> subjects = fundGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@GetMapping("/graph")
@Operation(summary = "查询一层资金流图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphVO graph = fundGraphService.getFundGraph(queryDTO);
return AjaxResult.success(graph);
}
@GetMapping("/edge-detail")
@Operation(summary = "查询资金边流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public TableDataInfo getEdgeDetail(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiFundGraphStatementVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiFundGraphStatementVO> result = fundGraphService.getEdgeDetails(page, queryDTO);
return getDataTable(result.getRecords(), result.getTotal());
}
@PostMapping("/manual-edge")
@Operation(summary = "新增手工资金流向")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult saveManualEdge(@RequestBody CcdiFundGraphManualEdgeSaveDTO saveDTO) {
try {
CcdiFundGraphEdgeVO edge = fundGraphService.saveManualEdge(saveDTO, SecurityUtils.getUsername());
return AjaxResult.success(edge);
} catch (IllegalArgumentException e) {
return AjaxResult.error(e.getMessage());
}
}
}

View File

@@ -29,6 +29,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -181,4 +182,14 @@ public class CcdiProjectOverviewController extends BaseController {
public void exportRiskDetails(HttpServletResponse response, Long projectId) { public void exportRiskDetails(HttpServletResponse response, Long projectId) {
overviewService.exportRiskDetails(response, projectId); overviewService.exportRiskDetails(response, projectId);
} }
/**
* 导出结果总览报告
*/
@RequestMapping(value = "/report/export", method = { RequestMethod.GET, RequestMethod.POST })
@Operation(summary = "导出结果总览报告")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportOverviewReport(HttpServletResponse response, Long projectId) {
overviewService.exportOverviewReport(response, projectId);
}
} }

View File

@@ -0,0 +1,55 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 关系图谱Controller
*/
@RestController
@RequestMapping("/ccdi/project/relation-graph")
@Tag(name = "关系图谱")
public class CcdiRelationGraphController extends BaseController {
@Resource
private ICcdiRelationGraphService relationGraphService;
@GetMapping("/search")
@Operation(summary = "查询关系图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
List<CcdiRelationGraphNodeVO> subjects = relationGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@GetMapping("/graph")
@Operation(summary = "查询一层关系图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphVO graph = relationGraphService.getRelationGraph(queryDTO);
return AjaxResult.success(graph);
}
@GetMapping("/suspected-enterprises")
@Operation(summary = "查询关系图谱疑似同名企业")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseVO result = relationGraphService.getSuspectedEnterprises(queryDTO);
return AjaxResult.success(result);
}
}

View File

@@ -0,0 +1,42 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱边明细查询条件
*/
@Data
public class CcdiFundGraphEdgeDetailQueryDTO {
/** 项目ID历史字段资金流图谱不按项目过滤 */
private Long projectId;
/** 身份证号、员工姓名或本方户名 */
private String keyword;
/** 主体节点object_key复用图谱公共SQL片段时兼容条件判断 */
private String objectKey;
/** 边起点 */
private String fromKey;
/** 边终点 */
private String toKey;
/** 方向1支出2收入 */
private String direction;
/** 交易开始时间 */
private String transactionStartTime;
/** 交易结束时间 */
private String transactionEndTime;
/** 最小金额 */
private BigDecimal amountMin;
/** 最大金额 */
private BigDecimal amountMax;
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 手工资金流向保存参数。
*/
@Data
public class CcdiFundGraphManualEdgeSaveDTO {
/** 起点主体object_key为空时默认使用当前查询中心 */
private String fromObjectKey;
/** 起点主体名称 */
private String fromName;
/** 终点主体object_key已有节点时传入 */
private String toObjectKey;
/** 终点主体名称;新建主体时必填 */
private String toName;
/** 终点主体身份证号/证件号有值时按md5(trim(idNo))复用主体 */
private String toIdNo;
/** 手工录入汇总金额 */
private BigDecimal amount;
/** 手工录入笔数 */
private Integer transactionCount;
/** 方向1支出2收入 */
private String direction;
/** 资金流向关系说明 */
private String relationDesc;
/** 来源说明 */
private String sourceDesc;
/** 分析备注 */
private String remark;
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱查询条件
*/
@Data
public class CcdiFundGraphQueryDTO {
/** 项目ID历史字段资金流图谱不按项目过滤 */
private Long projectId;
/** 身份证号、员工姓名或本方户名 */
private String keyword;
/** 主体节点object_key节点穿透时直接使用 */
private String objectKey;
/** 交易开始时间 */
private String transactionStartTime;
/** 交易结束时间 */
private String transactionEndTime;
/** 最小金额 */
private BigDecimal amountMin;
/** 最大金额 */
private BigDecimal amountMax;
/** 最小汇总金额默认1000 */
private BigDecimal minTotalAmount;
/** 方向1支出2收入 */
private String direction;
/** 返回边数量上限 */
private Integer limit;
/** 预留追溯层级,一期固定按一层处理 */
private Integer depth;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 关系图谱查询条件
*/
@Data
public class CcdiRelationGraphQueryDTO {
/** 项目ID历史字段关系图谱不按项目过滤 */
private Long projectId;
/** 身份证号、姓名、统一社会信用代码或节点object_key */
private String keyword;
/** 节点object_key节点穿透时直接使用 */
private String objectKey;
/** 返回边数量上限 */
private Integer limit;
/** 预留追溯层级,一期固定按一层处理 */
private Integer depth;
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 关系图谱疑似企业查询条件
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseQueryDTO {
/** 姓名 */
private String personName;
/** 证件号 */
private String certNo;
/** 出生日期yyyy-MM-dd */
private String birthDate;
/** 返回数量上限 */
private Integer limit;
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱汇总边
*/
@Data
public class CcdiFundGraphEdgeVO {
private String edgeKey;
private String fromKey;
private String toKey;
private String fromObjectKey;
private String toObjectKey;
private String fromName;
private String toName;
private BigDecimal totalAmount;
private Long transactionCount;
private String firstTrxDate;
private String lastTrxDate;
private String direction;
private String familyRelationType;
private String sourceType;
private String relationDesc;
private String sourceDesc;
private String remark;
private Integer depth;
private Boolean canTrace;
}

View File

@@ -0,0 +1,48 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱节点
*/
@Data
public class CcdiFundGraphNodeVO {
private String nodeKey;
private String objectKey;
private String nodeName;
private String idNo;
private String cinocsno;
private String idnoType;
private String staffId;
private String sourceType;
private String nodeType;
private String identityType;
private String relationType;
private Long accountCount;
private String createdTime;
private String updatedTime;
private Boolean canExpand;
private Integer depth;
private BigDecimal totalAmount;
private Long transactionCount;
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱边对应流水明细
*/
@Data
public class CcdiFundGraphStatementVO {
private Long bankStatementId;
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String cashType;
private String userMemo;
private BigDecimal amount;
private String direction;
private String familyRelationType;
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 资金流图谱结果
*/
@Data
public class CcdiFundGraphVO {
private CcdiFundGraphNodeVO centerNode;
private List<CcdiFundGraphNodeVO> nodes = new ArrayList<>();
private List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
private BigDecimal totalAmount = BigDecimal.ZERO;
private Long transactionCount = 0L;
private Integer maxDepth = 1;
private Boolean traceReserved = true;
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览报告风险模型汇总
*/
@Data
public class CcdiProjectOverviewReportModelSummaryVO {
private String modelCode;
private String modelName;
private Integer warningCount;
private Integer peopleCount;
private String peopleNames;
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览报告参数配置项
*/
@Data
public class CcdiProjectOverviewReportParamVO {
private String modelName;
private String paramName;
private String paramValue;
private String paramUnit;
private String paramDesc;
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
* 结果总览报告涉疑交易明细
*/
@Data
public class CcdiProjectOverviewReportSuspiciousTransactionVO {
private Long bankStatementId;
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String relatedStaffName;
private String relatedStaffCode;
private String userMemo;
private String cashType;
private String hitTags;
private BigDecimal displayAmount;
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览报告上传主体汇总
*/
@Data
public class CcdiProjectOverviewReportUploadSubjectVO {
private String subjectName;
private String accountNos;
private String minTrxDate;
private String maxTrxDate;
private Integer fileCount;
private String dataPeriod;
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.ccdi.project.domain.vo;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 结果总览一键导出报告
*/
@Data
public class CcdiProjectOverviewReportVO {
private CcdiProject project;
private List<CcdiProjectOverviewReportUploadSubjectVO> uploadSubjects = new ArrayList<>();
private List<CcdiProjectOverviewReportParamVO> params = new ArrayList<>();
private CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
private List<CcdiProjectOverviewReportModelSummaryVO> modelSummaries = new ArrayList<>();
private List<CcdiProjectRiskModelPeopleItemVO> riskPeople = new ArrayList<>();
private List<CcdiProjectOverviewReportSuspiciousTransactionVO> suspiciousTransactions = new ArrayList<>();
private List<CcdiProjectEmployeeCreditNegativeExcel> illegalPeople = new ArrayList<>();
private List<CcdiProjectAbnormalAccountExcel> abnormalAccounts = new ArrayList<>();
}

View File

@@ -33,4 +33,6 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private Boolean hasModelRuleHit; private Boolean hasModelRuleHit;
private Boolean hasNameListHit; private Boolean hasNameListHit;
private String nameListHitType;
} }

View File

@@ -0,0 +1,90 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 关系图谱边
*/
@Data
public class CcdiRelationGraphEdgeVO {
private String objectKey;
private String fromKey;
private String toKey;
private String fromObjectKey;
private String toObjectKey;
private String fromName;
private String toName;
private String edgeTable;
private String relationType;
private String companyName;
private String stockName;
private String stockType;
private String stockPercent;
private String shouldCapi;
private String shouldCapiValue;
private String shouldCapiUnit;
private String shoudDate;
private String pKeyNo;
private String operName;
private String operKeyNo;
private String personId;
private String relationName;
private String relationCertNo;
private String gender;
private String birthDate;
private String relationCertType;
private String mobilePhone1;
private String mobilePhone2;
private String wechatNo1;
private String wechatNo2;
private String wechatNo3;
private String contactAddress;
private BigDecimal annualIncome;
private String relationDesc;
private String status;
private String effectiveDate;
private String invalidDate;
private String remark;
private String dataSource;
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 关系图谱节点
*/
@Data
public class CcdiRelationGraphNodeVO {
private String objectKey;
private String nodeKey;
private String nodeName;
private String idNumber;
private String subjectType;
private String sourceType;
private String detailRefType;
private String detailRefKey;
private String createdTime;
private String updatedTime;
private Boolean canExpand;
private Integer depth;
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 关系图谱疑似企业明细
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseItemVO {
private String candidateKeyNo;
private String personName;
private String companyId;
private String companyName;
private String creditCode;
private String enterpriseStatus;
private String industryName;
private String relationType;
private String stockPercent;
/** 企业成立日期或当前可用的工商关系日期 */
private String establishDate;
private Integer ageAtEstablish;
private String matchReason;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 关系图谱疑似企业结果
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseVO {
/** 是否因同名候选过多被拦截 */
private Boolean blocked = false;
/** 拦截或空结果说明 */
private String message;
/** 同名工商keyno数量 */
private Integer sameNameKeyNoCount = 0;
/** 表格明细 */
private List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows = new ArrayList<>();
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 关系图谱结果
*/
@Data
public class CcdiRelationGraphVO {
private CcdiRelationGraphNodeVO centerNode;
private List<CcdiRelationGraphNodeVO> nodes = new ArrayList<>();
private List<CcdiRelationGraphEdgeVO> edges = new ArrayList<>();
private Long edgeCount = 0L;
private Integer maxDepth = 1;
}

View File

@@ -272,9 +272,11 @@ public interface CcdiBankTagAnalysisMapper {
* 微信支付宝提现超额 * 微信支付宝提现超额
* *
* @param projectId 项目ID * @param projectId 项目ID
* @param amountThreshold 提现金额阈值
* @return 对象命中结果 * @return 对象命中结果
*/ */
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId); List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId,
@Param("amountThreshold") BigDecimal amountThreshold);
/** /**
* 工资快速转出 * 工资快速转出

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 资金流图谱Mapper
*/
@Mapper
public interface CcdiFundGraphMapper {
List<CcdiFundGraphNodeVO> selectFundGraphSubjects(@Param("query") CcdiFundGraphQueryDTO query);
List<CcdiFundGraphEdgeVO> selectFundGraphEdges(@Param("query") CcdiFundGraphQueryDTO query);
List<CcdiFundGraphEdgeVO> selectFundGraphManualEdges(@Param("query") CcdiFundGraphQueryDTO query);
int countSubjectByObjectKey(@Param("objectKey") String objectKey);
int insertManualSubject(
@Param("objectKey") String objectKey,
@Param("idNo") String idNo,
@Param("name") String name
);
int insertManualEdge(
@Param("objectKey") String objectKey,
@Param("dto") CcdiFundGraphManualEdgeSaveDTO dto,
@Param("operator") String operator
);
Page<CcdiFundGraphStatementVO> selectFundGraphEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
@Param("query") CcdiFundGraphEdgeDetailQueryDTO query
);
}

View File

@@ -13,6 +13,9 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
@@ -72,6 +75,40 @@ public interface CcdiProjectOverviewMapper {
*/ */
List<CcdiProjectRiskModelCardVO> selectRiskModelCardsByProjectId(@Param("projectId") Long projectId); List<CcdiProjectRiskModelCardVO> selectRiskModelCardsByProjectId(@Param("projectId") Long projectId);
/**
* 查询报告上传主体汇总
*
* @param projectId 项目ID
* @return 上传主体汇总
*/
List<CcdiProjectOverviewReportUploadSubjectVO> selectReportUploadSubjects(@Param("projectId") Long projectId);
/**
* 查询报告风险模型汇总
*
* @param projectId 项目ID
* @return 风险模型汇总
*/
List<CcdiProjectOverviewReportModelSummaryVO> selectReportRiskModelSummaries(@Param("projectId") Long projectId);
/**
* 查询报告风险人员与异常点
*
* @param projectId 项目ID
* @return 风险人员与异常点
*/
List<CcdiProjectRiskModelPeopleItemVO> selectReportRiskPeople(@Param("projectId") Long projectId);
/**
* 查询报告涉疑交易明细
*
* @param query 查询条件
* @return 涉疑交易明细
*/
List<CcdiProjectOverviewReportSuspiciousTransactionVO> selectReportSuspiciousTransactionList(
@Param("query") CcdiProjectSuspiciousTransactionQueryDTO query
);
/** /**
* 分页查询风险模型命中人员 * 分页查询风险模型命中人员
* *

View File

@@ -0,0 +1,28 @@
package com.ruoyi.ccdi.project.mapper;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 关系图谱Mapper
*/
@Mapper
public interface CcdiRelationGraphMapper {
List<CcdiRelationGraphNodeVO> selectRelationGraphSubjects(@Param("query") CcdiRelationGraphQueryDTO query);
List<CcdiRelationGraphNodeVO> selectRelationGraphNodesByKeys(@Param("objectKeys") List<String> objectKeys);
List<CcdiRelationGraphEdgeVO> selectRelationGraphEdges(@Param("query") CcdiRelationGraphQueryDTO query);
int countSuspectedEnterpriseKeyNos(@Param("personName") String personName);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> selectSuspectedEnterprises(@Param("personName") String personName,
@Param("limit") Integer limit);
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.ccdi.project.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import java.util.List;
/**
* 资金流图谱Service接口
*/
public interface ICcdiFundGraphService {
List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO);
CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO);
Page<CcdiFundGraphStatementVO> getEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
CcdiFundGraphEdgeDetailQueryDTO queryDTO
);
CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator);
}

View File

@@ -125,6 +125,15 @@ public interface ICcdiProjectOverviewService {
default void exportRiskDetails(HttpServletResponse response, Long projectId) { default void exportRiskDetails(HttpServletResponse response, Long projectId) {
} }
/**
* 一键导出结果总览报告
*
* @param response 响应流
* @param projectId 项目ID
*/
default void exportOverviewReport(HttpServletResponse response, Long projectId) {
}
/** /**
* 导出项目员工负面征信 * 导出项目员工负面征信
* *

View File

@@ -0,0 +1,21 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import java.util.List;
/**
* 关系图谱Service接口
*/
public interface ICcdiRelationGraphService {
List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO);
CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO);
CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO);
}

View File

@@ -34,6 +34,7 @@ public class BankTagRuleConfigResolver {
Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")), Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")),
Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")), Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")), Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")),
Map.entry("WITHDRAW_AMT", Set.of("WITHDRAW_AMT")),
Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")), Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")),
Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")), Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")),
Map.entry("MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")), Map.entry("MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),

View File

@@ -285,7 +285,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects( case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(
projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT")) projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT"))
); );
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId); case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(
projectId, toBigDecimal(config.getThresholdValue("WITHDRAW_AMT"))
);
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId); case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId); case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);
case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId); case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId);

View File

@@ -0,0 +1,366 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import com.ruoyi.ccdi.project.mapper.CcdiFundGraphMapper;
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 资金流图谱Service实现
*/
@Service
public class CcdiFundGraphServiceImpl implements ICcdiFundGraphService {
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private static final BigDecimal DEFAULT_MIN_TOTAL_AMOUNT = new BigDecimal("1000");
private static final Comparator<CcdiFundGraphEdgeVO> EDGE_COMPARATOR = Comparator
.comparing(CcdiFundGraphServiceImpl::safeAmount, Comparator.reverseOrder())
.thenComparing(CcdiFundGraphServiceImpl::safeTransactionCount, Comparator.reverseOrder())
.thenComparing(CcdiFundGraphServiceImpl::safeDateText, Comparator.reverseOrder())
.thenComparing(edge -> normalizeSortText(edge == null ? null : edge.getEdgeKey()));
@Resource
private CcdiFundGraphMapper fundGraphMapper;
@Override
public List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
return Collections.emptyList();
}
return fundGraphMapper.selectFundGraphSubjects(query);
}
@Override
public CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
CcdiFundGraphNodeVO centerNode = resolveCenterNode(query);
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
return new CcdiFundGraphVO();
}
query.setObjectKey(centerNode.getObjectKey());
List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
List<CcdiFundGraphEdgeVO> realEdges = fundGraphMapper.selectFundGraphEdges(query);
if (realEdges != null) {
edges.addAll(realEdges);
}
List<CcdiFundGraphEdgeVO> manualEdges = fundGraphMapper.selectFundGraphManualEdges(query);
if (manualEdges != null) {
edges.addAll(manualEdges);
}
edges = sortAndLimitEdges(edges, query.getLimit());
CcdiFundGraphVO graph = new CcdiFundGraphVO();
graph.setCenterNode(centerNode);
graph.setEdges(edges);
graph.setNodes(buildNodes(centerNode, edges));
graph.setTransactionCount(edges.stream()
.map(CcdiFundGraphEdgeVO::getTransactionCount)
.filter(item -> item != null)
.reduce(0L, Long::sum));
graph.setTotalAmount(edges.stream()
.map(CcdiFundGraphEdgeVO::getTotalAmount)
.filter(item -> item != null)
.reduce(BigDecimal.ZERO, BigDecimal::add));
graph.setMaxDepth(1);
graph.setTraceReserved(true);
return graph;
}
@Override
public Page<CcdiFundGraphStatementVO> getEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
CcdiFundGraphEdgeDetailQueryDTO queryDTO
) {
CcdiFundGraphEdgeDetailQueryDTO query = normalizeDetailQuery(queryDTO);
if (isBlank(query.getFromKey()) || isBlank(query.getToKey())) {
return page;
}
return fundGraphMapper.selectFundGraphEdgeDetails(page, query);
}
@Override
public CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator) {
CcdiFundGraphManualEdgeSaveDTO dto = normalizeManualEdge(saveDTO);
String fromObjectKey = dto.getFromObjectKey();
String toObjectKey = resolveManualToObjectKey(dto);
dto.setToObjectKey(toObjectKey);
String edgeObjectKey = md5("MANUAL_EDGE|" + fromObjectKey + "|" + toObjectKey + "|" + dto.getDirection()
+ "|" + UUID.randomUUID());
fundGraphMapper.insertManualEdge(edgeObjectKey, dto, normalizeText(operator));
CcdiFundGraphEdgeVO edge = new CcdiFundGraphEdgeVO();
edge.setEdgeKey(edgeObjectKey);
edge.setFromKey(toSubjectKey(fromObjectKey));
edge.setToKey(toSubjectKey(toObjectKey));
edge.setFromObjectKey(fromObjectKey);
edge.setToObjectKey(toObjectKey);
edge.setFromName(dto.getFromName());
edge.setToName(dto.getToName());
edge.setTotalAmount(dto.getAmount());
edge.setTransactionCount(dto.getTransactionCount() == null ? 1L : dto.getTransactionCount().longValue());
edge.setDirection(dto.getDirection());
edge.setRelationDesc(dto.getRelationDesc());
edge.setSourceDesc(dto.getSourceDesc());
edge.setRemark(dto.getRemark());
edge.setSourceType("MANUAL");
edge.setDepth(1);
edge.setCanTrace(false);
return edge;
}
private CcdiFundGraphNodeVO resolveCenterNode(CcdiFundGraphQueryDTO query) {
if (isBlank(query.getObjectKey()) && isBlank(query.getKeyword())) {
return null;
}
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
if (subjects == null || subjects.isEmpty()) {
return null;
}
return subjects.get(0);
}
private List<CcdiFundGraphNodeVO> buildNodes(CcdiFundGraphNodeVO centerNode, List<CcdiFundGraphEdgeVO> edges) {
Map<String, CcdiFundGraphNodeVO> nodeMap = new LinkedHashMap<>();
Map<String, CcdiFundGraphNodeVO> subjectCache = new LinkedHashMap<>();
subjectCache.put(centerNode.getObjectKey(), centerNode);
addNode(nodeMap, centerNode, centerNode.getNodeKey(), centerNode.getObjectKey(), centerNode.getNodeName(),
centerNode.getRelationType(), centerNode.getCanExpand(), BigDecimal.ZERO, 0L);
for (CcdiFundGraphEdgeVO edge : edges) {
String centerObjectKey = centerNode.getObjectKey();
String fromRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getFromObjectKey())
? null
: edge.getFamilyRelationType();
String toRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getToObjectKey())
? null
: edge.getFamilyRelationType();
addNode(nodeMap, lookupSubject(edge.getFromObjectKey(), subjectCache), edge.getFromKey(),
edge.getFromObjectKey(), edge.getFromName(), fromRelationType, true, edge.getTotalAmount(),
edge.getTransactionCount());
addNode(nodeMap, lookupSubject(edge.getToObjectKey(), subjectCache), edge.getToKey(),
edge.getToObjectKey(), edge.getToName(), toRelationType, edge.getCanTrace(),
edge.getTotalAmount(), edge.getTransactionCount());
}
return List.copyOf(nodeMap.values());
}
private CcdiFundGraphNodeVO lookupSubject(String objectKey, Map<String, CcdiFundGraphNodeVO> subjectCache) {
if (isBlank(objectKey)) {
return null;
}
if (subjectCache.containsKey(objectKey)) {
return subjectCache.get(objectKey);
}
CcdiFundGraphQueryDTO query = new CcdiFundGraphQueryDTO();
query.setObjectKey(objectKey);
query.setLimit(DEFAULT_LIMIT);
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
CcdiFundGraphNodeVO subject = subjects == null || subjects.isEmpty() ? null : subjects.get(0);
subjectCache.put(objectKey, subject);
return subject;
}
private String resolveManualToObjectKey(CcdiFundGraphManualEdgeSaveDTO dto) {
if (!isBlank(dto.getToObjectKey())) {
ensureManualSubject(dto.getToObjectKey(), dto.getToIdNo(), dto.getToName());
return dto.getToObjectKey();
}
String objectKey = !isBlank(dto.getToIdNo())
? md5(dto.getToIdNo())
: md5("MANUAL_NODE|" + dto.getToName() + "|" + UUID.randomUUID());
dto.setToObjectKey(objectKey);
ensureManualSubject(objectKey, dto.getToIdNo(), dto.getToName());
return objectKey;
}
private void ensureManualSubject(String objectKey, String idNo, String name) {
if (fundGraphMapper.countSubjectByObjectKey(objectKey) > 0) {
return;
}
fundGraphMapper.insertManualSubject(objectKey, normalizeText(idNo), normalizeText(name));
}
private CcdiFundGraphManualEdgeSaveDTO normalizeManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO) {
CcdiFundGraphManualEdgeSaveDTO dto = saveDTO == null ? new CcdiFundGraphManualEdgeSaveDTO() : saveDTO;
dto.setFromObjectKey(normalizeText(dto.getFromObjectKey()));
dto.setFromName(normalizeText(dto.getFromName()));
dto.setToObjectKey(normalizeText(dto.getToObjectKey()));
dto.setToName(normalizeText(dto.getToName()));
dto.setToIdNo(normalizeText(dto.getToIdNo()));
dto.setDirection(normalizeText(dto.getDirection()));
dto.setRelationDesc(normalizeText(dto.getRelationDesc()));
dto.setSourceDesc(normalizeText(dto.getSourceDesc()));
dto.setRemark(normalizeText(dto.getRemark()));
if (isBlank(dto.getFromObjectKey())) {
throw new IllegalArgumentException("起点主体不能为空");
}
if (isBlank(dto.getToObjectKey()) && isBlank(dto.getToName())) {
throw new IllegalArgumentException("终点主体不能为空");
}
if (isBlank(dto.getDirection())) {
dto.setDirection("1");
}
if (dto.getAmount() == null) {
dto.setAmount(BigDecimal.ZERO);
}
if (dto.getTransactionCount() == null || dto.getTransactionCount() <= 0) {
dto.setTransactionCount(1);
}
return dto;
}
private void addNode(
Map<String, CcdiFundGraphNodeVO> nodeMap,
CcdiFundGraphNodeVO subject,
String nodeKey,
String objectKey,
String nodeName,
String relationType,
Boolean canExpand,
BigDecimal edgeAmount,
Long edgeCount
) {
if (isBlank(nodeKey)) {
return;
}
CcdiFundGraphNodeVO node = nodeMap.computeIfAbsent(nodeKey, key -> {
CcdiFundGraphNodeVO item = new CcdiFundGraphNodeVO();
item.setNodeKey(key);
item.setObjectKey(objectKey);
item.setNodeName(subject != null && !isBlank(subject.getNodeName())
? subject.getNodeName()
: (isBlank(nodeName) ? "未知主体" : nodeName));
item.setIdNo(subject == null ? null : subject.getIdNo());
item.setCinocsno(subject == null ? null : subject.getCinocsno());
item.setIdnoType(subject == null ? null : subject.getIdnoType());
item.setStaffId(subject == null ? null : subject.getStaffId());
item.setSourceType(subject == null ? null : subject.getSourceType());
item.setNodeType(subject != null && !isBlank(subject.getNodeType()) ? subject.getNodeType() : "PERSON");
item.setIdentityType(subject != null && !isBlank(subject.getIdentityType()) ? subject.getIdentityType() : "IDNO");
item.setRelationType(relationType);
item.setAccountCount(subject == null ? 0L : subject.getAccountCount());
item.setCreatedTime(subject == null ? null : subject.getCreatedTime());
item.setUpdatedTime(subject == null ? null : subject.getUpdatedTime());
item.setCanExpand(Boolean.TRUE.equals(canExpand));
item.setDepth(1);
item.setTotalAmount(BigDecimal.ZERO);
item.setTransactionCount(0L);
return item;
});
if (isBlank(node.getObjectKey())) {
node.setObjectKey(objectKey);
}
if (isBlank(node.getRelationType())) {
node.setRelationType(relationType);
}
if (Boolean.TRUE.equals(canExpand)) {
node.setCanExpand(true);
}
node.setTotalAmount(node.getTotalAmount().add(edgeAmount == null ? BigDecimal.ZERO : edgeAmount));
node.setTransactionCount(node.getTransactionCount() + (edgeCount == null ? 0L : edgeCount));
}
private CcdiFundGraphQueryDTO normalizeGraphQuery(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = queryDTO == null ? new CcdiFundGraphQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setObjectKey(normalizeText(query.getObjectKey()));
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
query.setDirection(normalizeText(query.getDirection()));
if (query.getMinTotalAmount() == null) {
query.setMinTotalAmount(DEFAULT_MIN_TOTAL_AMOUNT);
}
query.setLimit(normalizeLimit(query.getLimit()));
query.setDepth(1);
return query;
}
private CcdiFundGraphEdgeDetailQueryDTO normalizeDetailQuery(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
CcdiFundGraphEdgeDetailQueryDTO query = queryDTO == null ? new CcdiFundGraphEdgeDetailQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setFromKey(normalizeText(query.getFromKey()));
query.setToKey(normalizeText(query.getToKey()));
query.setDirection(normalizeText(query.getDirection()));
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
return query;
}
private Integer normalizeLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private List<CcdiFundGraphEdgeVO> sortAndLimitEdges(List<CcdiFundGraphEdgeVO> edges, Integer limit) {
if (edges == null || edges.isEmpty()) {
return Collections.emptyList();
}
List<CcdiFundGraphEdgeVO> sorted = new ArrayList<>(edges);
sorted.sort(EDGE_COMPARATOR);
int finalLimit = normalizeLimit(limit);
if (sorted.size() > finalLimit) {
return List.copyOf(sorted.subList(0, finalLimit));
}
return List.copyOf(sorted);
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private String toSubjectKey(String objectKey) {
return "idno_node/" + objectKey;
}
private String md5(String value) {
return DigestUtils.md5DigestAsHex(value.trim().getBytes(StandardCharsets.UTF_8));
}
private static BigDecimal safeAmount(CcdiFundGraphEdgeVO edge) {
return edge == null || edge.getTotalAmount() == null ? BigDecimal.ZERO : edge.getTotalAmount();
}
private static Long safeTransactionCount(CcdiFundGraphEdgeVO edge) {
return edge == null || edge.getTransactionCount() == null ? 0L : edge.getTransactionCount();
}
private static String safeDateText(CcdiFundGraphEdgeVO edge) {
return normalizeSortText(edge == null ? null : edge.getLastTrxDate());
}
private static String normalizeSortText(String value) {
return value == null ? "" : value;
}
}

View File

@@ -23,6 +23,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -53,6 +54,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
private ICcdiProjectService projectService; private ICcdiProjectService projectService;
@Resource @Resource
@Lazy
private ICcdiBankTagService bankTagService; private ICcdiBankTagService bankTagService;
@Override @Override

View File

@@ -0,0 +1,623 @@
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.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.file.FileUtils;
import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import org.apache.fontbox.ttf.TrueTypeCollection;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.stereotype.Component;
/**
* 结果总览PDF报告导出器
*/
@Component
public class CcdiProjectOverviewReportPdfExporter {
private static final String CONTENT_TYPE = "application/pdf";
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00");
public void export(HttpServletResponse response, CcdiProjectOverviewReportVO report) throws IOException {
response.setContentType(CONTENT_TYPE);
FileUtils.setAttachmentResponseHeader(
response,
safeFileName(report.getProject().getProjectName()) + "_初核结果报告.pdf"
);
try (PDDocument document = new PDDocument()) {
PDType0Font font = loadChineseFont(document);
PdfPageWriter writer = new PdfPageWriter(document, font);
writer.newPage();
writeCover(writer, report);
writeUploadSubjects(writer, report.getUploadSubjects());
writeParams(writer, report.getParams());
writeRiskModels(writer, report);
writeRiskDetails(writer, report);
writer.close();
document.save(response.getOutputStream());
}
}
private void writeCover(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.title("初核结果报告");
writer.text("项目名称:" + safeText(report.getProject().getProjectName()), 12, Color.GRAY);
writer.text("导出时间:" + LocalDateTime.now().format(EXPORT_TIME_FORMATTER), 12, Color.GRAY);
writer.separator();
}
private void writeUploadSubjects(
PdfPageWriter writer,
List<CcdiProjectOverviewReportUploadSubjectVO> rows
) throws IOException {
writer.section("一、上传文件");
writer.table(
List.of("序号", "主体名称", "主体账号", "数据周期", "文件数"),
indexedRows(rows).stream()
.map(item -> List.of(
item.index(),
safeText(item.row().getSubjectName()),
maskAccountList(item.row().getAccountNos()),
safeText(item.row().getDataPeriod()),
formatCount(item.row().getFileCount(), "")
))
.collect(Collectors.toList()),
new float[] { 0.07F, 0.2F, 0.45F, 0.14F, 0.14F },
"暂无上传文件数据"
);
}
private void writeParams(PdfPageWriter writer, List<CcdiProjectOverviewReportParamVO> rows) throws IOException {
writer.section("二、参数配置");
writer.table(
List.of("模型名称", "监测项", "参数值", "单位", "描述"),
rows.stream()
.map(item -> List.of(
safeText(item.getModelName()),
safeText(item.getParamName()),
safeText(item.getParamValue()),
safeText(item.getParamUnit()),
safeText(item.getParamDesc())
))
.collect(Collectors.toList()),
new float[] { 0.18F, 0.26F, 0.14F, 0.12F, 0.3F },
"暂无参数配置数据"
);
}
private void writeRiskModels(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.section("三、风险模型");
writer.metrics(report.getDashboard().getStats());
writer.subsection("风险模型汇总");
writer.table(
List.of("模型名称", "预警数量", "涉及人员"),
report.getModelSummaries().stream()
.map(item -> List.of(
safeText(item.getModelName()),
String.valueOf(defaultZero(item.getWarningCount())),
formatPeopleSummary(item)
))
.collect(Collectors.toList()),
new float[] { 0.26F, 0.14F, 0.6F },
"暂无风险模型汇总数据"
);
writer.subsection("风险人员与异常点");
writer.table(
List.of("姓名", "工号", "身份证号", "所属部门", "命中模型", "异常标签"),
report.getRiskPeople().stream()
.map(item -> List.of(
safeText(item.getStaffName()),
safeText(item.getStaffCode()),
maskIdCard(item.getIdNo()),
safeText(item.getDepartment()),
joinText(item.getModelNames()),
formatHitTags(item.getHitTagList())
))
.collect(Collectors.toList()),
new float[] { 0.1F, 0.11F, 0.16F, 0.14F, 0.24F, 0.25F },
"暂无风险人员与异常点数据"
);
}
private void writeRiskDetails(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.section("四、风险明细");
writer.subsection("1. 涉疑交易明细表(共" + report.getSuspiciousTransactions().size() + "条)");
writer.table(
List.of("交易时间", "本方账户", "对方账户", "关联员工", "摘要/交易类型", "异常标签", "交易金额"),
report.getSuspiciousTransactions().stream()
.map(item -> List.of(
safeText(item.getTrxDate()),
formatAccount(item.getLeAccountNo(), item.getLeAccountName()),
formatAccount(item.getCustomerAccountNo(), item.getCustomerAccountName()),
formatRelatedStaff(item.getRelatedStaffName(), item.getRelatedStaffCode()),
formatSummaryAndCashType(item.getUserMemo(), item.getCashType()),
safeText(item.getHitTags()),
formatMoney(item.getDisplayAmount())
))
.collect(Collectors.toList()),
new float[] { 0.14F, 0.16F, 0.16F, 0.12F, 0.17F, 0.16F, 0.09F },
"暂无涉疑交易明细"
);
writer.subsection("2. 违法信息人员表(共" + report.getIllegalPeople().size() + "人)");
writer.table(
List.of("姓名", "身份证号", "最近查询日期", "民事案件笔数", "民事案件金额", "强制执行笔数", "强制执行金额", "行政处罚笔数", "行政处罚金额"),
report.getIllegalPeople().stream()
.map(item -> List.of(
safeText(item.getPersonName()),
maskIdCard(item.getPersonId()),
safeText(item.getQueryDate()),
String.valueOf(defaultZero(item.getCivilCnt())),
formatMoney(item.getCivilLmt()),
String.valueOf(defaultZero(item.getEnforceCnt())),
formatMoney(item.getEnforceLmt()),
String.valueOf(defaultZero(item.getAdmCnt())),
formatMoney(item.getAdmLmt())
))
.collect(Collectors.toList()),
new float[] { 0.09F, 0.15F, 0.12F, 0.1F, 0.11F, 0.1F, 0.11F, 0.1F, 0.12F },
"暂无违法信息人员数据"
);
writer.subsection("3. 异常账户信息表(共" + report.getAbnormalAccounts().size() + "条)");
writer.table(
List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态"),
report.getAbnormalAccounts().stream()
.map(item -> List.of(
maskAccount(item.getAccountNo()),
safeText(item.getAccountName()),
safeText(item.getBankName()),
safeText(item.getAbnormalType()),
safeText(item.getAbnormalTime()),
safeText(item.getStatus())
))
.collect(Collectors.toList()),
new float[] { 0.18F, 0.13F, 0.2F, 0.23F, 0.14F, 0.12F },
"暂无异常账户信息"
);
}
private PDType0Font loadChineseFont(PDDocument document) throws IOException {
List<String> candidates = List.of(
"C:/Windows/Fonts/NotoSansSC-VF.ttf",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsunb.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"
);
for (String path : candidates) {
File file = new File(path);
if (!file.exists() || !file.isFile()) {
continue;
}
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".ttf")) {
return PDType0Font.load(document, file);
}
if (lowerPath.endsWith(".ttc")) {
PDType0Font font = loadFirstCollectionFont(document, file);
if (font != null) {
return font;
}
}
}
throw new ServiceException("未找到可用中文字体无法导出PDF报告");
}
private PDType0Font loadFirstCollectionFont(PDDocument document, File file) throws IOException {
AtomicReference<PDType0Font> font = new AtomicReference<>();
try (TrueTypeCollection collection = new TrueTypeCollection(file)) {
collection.processAllFonts(typeFont -> {
if (font.get() == null) {
font.set(PDType0Font.load(document, typeFont, true));
}
});
}
return font.get();
}
private List<IndexedUploadSubject> indexedRows(List<CcdiProjectOverviewReportUploadSubjectVO> rows) {
List<IndexedUploadSubject> result = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
result.add(new IndexedUploadSubject(String.valueOf(i + 1), rows.get(i)));
}
return result;
}
private String formatPeopleSummary(CcdiProjectOverviewReportModelSummaryVO item) {
String names = safeText(item.getPeopleNames());
if ("-".equals(names)) {
return names;
}
List<String> people = Arrays.stream(names.split(""))
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
if (people.size() <= 4) {
return String.join("", people);
}
return String.join("", people.subList(0, 4)) + "" + defaultZero(item.getPeopleCount()) + "";
}
private String formatHitTags(List<CcdiProjectRiskHitTagVO> tags) {
if (tags == null || tags.isEmpty()) {
return "-";
}
String text = tags.stream()
.map(CcdiProjectRiskHitTagVO::getRuleName)
.filter(Objects::nonNull)
.filter(value -> !value.isBlank())
.distinct()
.collect(Collectors.joining(""));
return text.isBlank() ? "-" : text;
}
private String joinText(List<String> values) {
if (values == null || values.isEmpty()) {
return "-";
}
String text = values.stream()
.filter(Objects::nonNull)
.filter(value -> !value.isBlank())
.distinct()
.collect(Collectors.joining(""));
return text.isBlank() ? "-" : text;
}
private String formatRelatedStaff(String name, String code) {
if (name == null || name.isBlank()) {
return "-";
}
if (code == null || code.isBlank()) {
return name;
}
return name + "(" + code + ")";
}
private String formatSummaryAndCashType(String summary, String cashType) {
return safeText(summary) + "/" + safeText(cashType);
}
private String formatAccount(String accountNo, String accountName) {
String masked = maskAccount(accountNo);
String name = safeText(accountName);
if ("-".equals(name)) {
return masked;
}
return masked + "\n" + name;
}
private String maskAccountList(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return Arrays.stream(value.split("、|,|"))
.map(String::trim)
.filter(item -> !item.isBlank())
.map(this::maskAccount)
.distinct()
.collect(Collectors.joining(""));
}
private String maskAccount(String value) {
if (value == null || value.isBlank()) {
return "-";
}
String text = value.trim().replaceAll("\\s+", "");
if (text.length() <= 8) {
return text.length() <= 4 ? text : text.substring(0, 2) + "****" + text.substring(text.length() - 2);
}
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
}
private String maskIdCard(String value) {
if (value == null || value.isBlank()) {
return "-";
}
String text = value.trim();
if (text.length() < 10) {
return text;
}
return text.substring(0, 3) + "***********" + text.substring(text.length() - 4);
}
private String formatCount(Integer value, String unit) {
return defaultZero(value) + unit;
}
private String formatMoney(BigDecimal value) {
if (value == null) {
return "-";
}
return MONEY_FORMAT.format(value);
}
private Integer defaultZero(Integer value) {
return value == null ? 0 : value;
}
private String safeText(String value) {
return value == null || value.isBlank() ? "-" : value;
}
private String safeFileName(String value) {
String text = safeText(value);
return text.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private record IndexedUploadSubject(String index, CcdiProjectOverviewReportUploadSubjectVO row) {
}
private static class PdfPageWriter {
private static final float MARGIN = 36F;
private static final PDRectangle LANDSCAPE_A4 = new PDRectangle(
PDRectangle.A4.getHeight(),
PDRectangle.A4.getWidth()
);
private static final float CONTENT_WIDTH = LANDSCAPE_A4.getWidth() - MARGIN * 2;
private static final float PAGE_TOP = LANDSCAPE_A4.getHeight() - MARGIN;
private static final float PAGE_BOTTOM = MARGIN;
private static final float BODY_FONT_SIZE = 9F;
private static final float HEADER_FONT_SIZE = 9F;
private static final float TITLE_FONT_SIZE = 22F;
private static final float SECTION_FONT_SIZE = 15F;
private static final float SUBSECTION_FONT_SIZE = 12F;
private static final float LINE_HEIGHT = 12F;
private static final float CELL_PADDING = 5F;
private static final float TABLE_AFTER_GAP = 32F;
private final PDDocument document;
private final PDType0Font font;
private PDPageContentStream content;
private float y;
PdfPageWriter(PDDocument document, PDType0Font font) {
this.document = document;
this.font = font;
}
void newPage() throws IOException {
close();
PDPage page = new PDPage(LANDSCAPE_A4);
document.addPage(page);
content = new PDPageContentStream(document, page);
y = PAGE_TOP;
}
void close() throws IOException {
if (content != null) {
content.close();
content = null;
}
}
void title(String text) throws IOException {
writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F, true);
}
void text(String text, float fontSize, Color color) throws IOException {
writeLine(text, fontSize, color, 0F, 18F);
}
void section(String text) throws IOException {
ensureSpace(32F);
writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F, true);
}
void subsection(String text) throws IOException {
ensureSpace(26F);
writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F, true);
}
void separator() throws IOException {
ensureSpace(16F);
content.setStrokingColor(new Color(31, 78, 121));
content.setLineWidth(1.4F);
content.moveTo(MARGIN, y);
content.lineTo(MARGIN + CONTENT_WIDTH, y);
content.stroke();
y -= 22F;
}
void metrics(List<CcdiProjectOverviewStatVO> stats) throws IOException {
ensureSpace(58F);
float cellWidth = CONTENT_WIDTH / Math.max(stats.size(), 1);
float x = MARGIN;
float rowHeight = 50F;
for (CcdiProjectOverviewStatVO stat : stats) {
drawRect(x, y - rowHeight, cellWidth, rowHeight, null);
drawCenteredText(String.valueOf(stat.getValue()), x, y - 18F, cellWidth, 18F, new Color(31, 78, 121));
drawCenteredText(stat.getLabel(), x, y - 36F, cellWidth, 10F, Color.GRAY);
x += cellWidth;
}
y -= rowHeight + 18F;
}
void table(
List<String> headers,
List<List<String>> rows,
float[] widthRatios,
String emptyText
) throws IOException {
List<List<String>> safeRows = rows.isEmpty()
? List.of(List.of(emptyText))
: rows;
List<String> safeHeaders = rows.isEmpty()
? List.of(headers.get(0))
: headers;
float[] widths = rows.isEmpty()
? new float[] { CONTENT_WIDTH }
: calculateWidths(widthRatios);
drawHeader(safeHeaders, widths);
for (List<String> row : safeRows) {
drawRow(row, widths, false);
}
y -= TABLE_AFTER_GAP;
}
private float[] calculateWidths(float[] ratios) {
float[] widths = new float[ratios.length];
for (int i = 0; i < ratios.length; i++) {
widths[i] = CONTENT_WIDTH * ratios[i];
}
return widths;
}
private void drawHeader(List<String> headers, float[] widths) throws IOException {
drawRow(headers, widths, true);
}
private void drawRow(List<String> cells, float[] widths, boolean header) throws IOException {
List<List<String>> wrappedCells = new ArrayList<>();
float rowHeight = 0F;
for (int i = 0; i < widths.length; i++) {
String text = i < cells.size() ? cells.get(i) : "";
List<String> lines = wrapText(text, widths[i] - CELL_PADDING * 2, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE);
wrappedCells.add(lines);
rowHeight = Math.max(rowHeight, lines.size() * LINE_HEIGHT + CELL_PADDING * 2);
}
rowHeight = Math.max(rowHeight, 24F);
ensureSpace(rowHeight + 4F);
float x = MARGIN;
for (int i = 0; i < widths.length; i++) {
Color background = header ? new Color(234, 241, 248) : null;
drawRect(x, y - rowHeight, widths[i], rowHeight, background);
drawCellText(wrappedCells.get(i), x + CELL_PADDING, y - CELL_PADDING - (header ? HEADER_FONT_SIZE : BODY_FONT_SIZE), header);
x += widths[i];
}
y -= rowHeight;
}
private void drawRect(float x, float bottomY, float width, float height, Color fill) throws IOException {
if (fill != null) {
content.setNonStrokingColor(fill);
content.addRect(x, bottomY, width, height);
content.fill();
}
content.setStrokingColor(new Color(205, 217, 229));
content.setLineWidth(0.5F);
content.addRect(x, bottomY, width, height);
content.stroke();
}
private void drawCellText(List<String> lines, float x, float startY, boolean header) throws IOException {
content.beginText();
content.setNonStrokingColor(header ? new Color(24, 59, 90) : new Color(31, 41, 55));
content.setFont(font, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE);
content.newLineAtOffset(x, startY);
for (int i = 0; i < lines.size(); i++) {
if (i > 0) {
content.newLineAtOffset(0, -LINE_HEIGHT);
}
content.showText(lines.get(i));
}
content.endText();
}
private void drawCenteredText(String text, float x, float baselineY, float width, float fontSize, Color color)
throws IOException {
float textWidth = textWidth(text, fontSize);
content.beginText();
content.setNonStrokingColor(color);
content.setFont(font, fontSize);
content.newLineAtOffset(x + (width - textWidth) / 2F, baselineY);
content.showText(text);
content.endText();
}
private void writeLine(String text, float fontSize, Color color, float indent, float advance) throws IOException {
writeLine(text, fontSize, color, indent, advance, false);
}
private void writeLine(
String text,
float fontSize,
Color color,
float indent,
float advance,
boolean bold
) throws IOException {
ensureSpace(advance);
content.beginText();
content.setNonStrokingColor(color);
content.setFont(font, fontSize);
content.newLineAtOffset(MARGIN + indent, y);
content.showText(text);
content.endText();
if (bold) {
content.beginText();
content.setNonStrokingColor(color);
content.setFont(font, fontSize);
content.newLineAtOffset(MARGIN + indent + 0.25F, y);
content.showText(text);
content.endText();
}
y -= advance;
}
private void ensureSpace(float height) throws IOException {
if (y - height < PAGE_BOTTOM) {
newPage();
}
}
private List<String> wrapText(String text, float maxWidth, float fontSize) throws IOException {
String safeText = text == null || text.isBlank() ? "-" : text;
List<String> result = new ArrayList<>();
for (String part : safeText.split("\\n")) {
wrapPart(part, maxWidth, fontSize, result);
}
return result.isEmpty() ? List.of("-") : result;
}
private void wrapPart(String text, float maxWidth, float fontSize, List<String> result) throws IOException {
StringBuilder current = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
String next = String.valueOf(text.charAt(i));
if (textWidth(current + next, fontSize) > maxWidth && current.length() > 0) {
result.add(current.toString());
current.setLength(0);
}
current.append(next);
}
if (current.length() > 0) {
result.add(current.toString());
}
}
private float textWidth(String text, float fontSize) throws IOException {
return font.getStringWidth(text) / 1000F * fontSize;
}
}
}

View File

@@ -26,6 +26,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
@@ -37,21 +39,25 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService; import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -81,6 +87,13 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource @Resource
private CcdiProjectRiskDetailWorkbookExporter workbookExporter; private CcdiProjectRiskDetailWorkbookExporter workbookExporter;
@Resource
private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
@Resource
@Lazy
private ICcdiModelParamService modelParamService;
@Override @Override
public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) { public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) {
CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId); CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId);
@@ -303,6 +316,38 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
} }
} }
@Override
public void exportOverviewReport(HttpServletResponse response, Long projectId) {
CcdiProject project = getRequiredProject(projectId);
CcdiProjectSuspiciousTransactionQueryDTO suspiciousQuery = new CcdiProjectSuspiciousTransactionQueryDTO();
suspiciousQuery.setProjectId(projectId);
suspiciousQuery.setSuspiciousType("ALL");
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
report.setProject(project);
report.setDashboard(getDashboard(projectId));
report.setUploadSubjects(defaultList(overviewMapper.selectReportUploadSubjects(projectId)).stream()
.peek(item -> item.setDataPeriod(formatDataPeriod(item.getMinTrxDate(), item.getMaxTrxDate())))
.toList());
report.setParams(buildReportParams(projectId));
report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId)));
report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream()
.peek(item -> item.setActionLabel(ACTION_LABEL))
.toList());
report.setSuspiciousTransactions(defaultList(
overviewMapper.selectReportSuspiciousTransactionList(suspiciousQuery)
));
report.setIllegalPeople(exportEmployeeCreditNegative(projectId));
report.setAbnormalAccounts(exportAbnormalAccountPeople(projectId));
try {
reportPdfExporter.export(response, report);
} catch (IOException e) {
throw new ServiceException("导出结果总览报告失败");
}
}
@Override @Override
public List<CcdiProjectEmployeeCreditNegativeExcel> exportEmployeeCreditNegative(Long projectId) { public List<CcdiProjectEmployeeCreditNegativeExcel> exportEmployeeCreditNegative(Long projectId) {
ensureProjectExists(projectId); ensureProjectExists(projectId);
@@ -511,6 +556,31 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row; return row;
} }
private List<CcdiProjectOverviewReportParamVO> buildReportParams(Long projectId) {
ModelParamAllVO response = modelParamService.selectAllParams(projectId);
return defaultList(response == null ? null : response.getModels()).stream()
.flatMap(model -> defaultList(model.getParams()).stream().map(param -> {
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName(model.getModelName());
row.setParamName(param.getParamName());
row.setParamValue(param.getParamValue());
row.setParamUnit(param.getParamUnit());
row.setParamDesc(param.getParamDesc());
return row;
}))
.toList();
}
private String formatDataPeriod(String minTrxDate, String maxTrxDate) {
if (minTrxDate == null || minTrxDate.isBlank() || maxTrxDate == null || maxTrxDate.isBlank()) {
return "-";
}
LocalDate start = LocalDate.parse(minTrxDate);
LocalDate end = LocalDate.parse(maxTrxDate);
int months = (end.getYear() - start.getYear()) * 12 + end.getMonthValue() - start.getMonthValue() + 1;
return Math.max(months, 1) + "个月";
}
private String formatRelatedStaff(String relatedStaffName, String relatedStaffCode) { private String formatRelatedStaff(String relatedStaffName, String relatedStaffCode) {
if (relatedStaffName == null || relatedStaffName.isBlank()) { if (relatedStaffName == null || relatedStaffName.isBlank()) {
return null; return null;

View File

@@ -0,0 +1,284 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import com.ruoyi.ccdi.project.mapper.CcdiRelationGraphMapper;
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 关系图谱Service实现
*/
@Service
public class CcdiRelationGraphServiceImpl implements ICcdiRelationGraphService {
private static final int DEFAULT_LIMIT = 80;
private static final int MAX_LIMIT = 200;
private static final int DEFAULT_SUSPECTED_LIMIT = 10;
private static final int MAX_SUSPECTED_LIMIT = 20;
private static final int SAME_NAME_BLOCK_THRESHOLD = 20;
private static final String NODE_PREFIX = "rel_node/";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Resource
private CcdiRelationGraphMapper relationGraphMapper;
@Override
public List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
return Collections.emptyList();
}
return relationGraphMapper.selectRelationGraphSubjects(query);
}
@Override
public CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
CcdiRelationGraphNodeVO centerNode = resolveCenterNode(query);
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
return new CcdiRelationGraphVO();
}
query.setObjectKey(centerNode.getObjectKey());
List<CcdiRelationGraphEdgeVO> edges = relationGraphMapper.selectRelationGraphEdges(query);
if (edges == null) {
edges = Collections.emptyList();
}
List<CcdiRelationGraphNodeVO> nodes = buildNodes(centerNode, edges);
CcdiRelationGraphVO graph = new CcdiRelationGraphVO();
graph.setCenterNode(centerNode);
graph.setNodes(nodes);
graph.setEdges(edges);
graph.setEdgeCount((long) edges.size());
graph.setMaxDepth(1);
return graph;
}
@Override
public CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseVO result = new CcdiRelationGraphSuspectedEnterpriseVO();
CcdiRelationGraphSuspectedEnterpriseQueryDTO query = normalizeSuspectedEnterpriseQuery(queryDTO);
if (isBlank(query.getPersonName())) {
result.setMessage("缺少人员姓名,无法按工商同名主体召回");
return result;
}
int sameNameKeyNoCount = relationGraphMapper.countSuspectedEnterpriseKeyNos(query.getPersonName());
result.setSameNameKeyNoCount(sameNameKeyNoCount);
if (sameNameKeyNoCount > SAME_NAME_BLOCK_THRESHOLD) {
result.setBlocked(true);
result.setMessage("同名工商主体过多,请结合交易对手企业名称或其他线索进一步筛选");
return result;
}
LocalDate birthDate = resolveBirthDate(query);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows =
relationGraphMapper.selectSuspectedEnterprises(query.getPersonName(), MAX_SUSPECTED_LIMIT);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> filteredRows = new ArrayList<>();
if (rows != null) {
for (CcdiRelationGraphSuspectedEnterpriseItemVO row : rows) {
if (row == null) {
continue;
}
row.setPersonName(query.getPersonName());
if (applyAgeRule(row, birthDate)) {
filteredRows.add(row);
if (filteredRows.size() >= query.getLimit()) {
break;
}
}
}
}
result.setRows(filteredRows);
if (filteredRows.isEmpty()) {
result.setMessage("未发现可展示的疑似同名企业");
}
return result;
}
private CcdiRelationGraphNodeVO resolveCenterNode(CcdiRelationGraphQueryDTO query) {
List<CcdiRelationGraphNodeVO> subjects = relationGraphMapper.selectRelationGraphSubjects(query);
if (subjects == null || subjects.isEmpty()) {
return null;
}
return subjects.get(0);
}
private List<CcdiRelationGraphNodeVO> buildNodes(CcdiRelationGraphNodeVO centerNode, List<CcdiRelationGraphEdgeVO> edges) {
Set<String> objectKeys = new LinkedHashSet<>();
objectKeys.add(centerNode.getObjectKey());
for (CcdiRelationGraphEdgeVO edge : edges) {
edge.setFromObjectKey(toObjectKey(edge.getFromKey()));
edge.setToObjectKey(toObjectKey(edge.getToKey()));
if (!isBlank(edge.getFromObjectKey())) {
objectKeys.add(edge.getFromObjectKey());
}
if (!isBlank(edge.getToObjectKey())) {
objectKeys.add(edge.getToObjectKey());
}
}
List<CcdiRelationGraphNodeVO> rawNodes = relationGraphMapper.selectRelationGraphNodesByKeys(new ArrayList<>(objectKeys));
Map<String, CcdiRelationGraphNodeVO> nodeMap = new LinkedHashMap<>();
if (rawNodes != null) {
for (CcdiRelationGraphNodeVO node : rawNodes) {
enrichNode(node, centerNode);
nodeMap.put(node.getObjectKey(), node);
}
}
if (!nodeMap.containsKey(centerNode.getObjectKey())) {
enrichNode(centerNode, centerNode);
nodeMap.put(centerNode.getObjectKey(), centerNode);
}
for (CcdiRelationGraphEdgeVO edge : edges) {
CcdiRelationGraphNodeVO fromNode = nodeMap.get(edge.getFromObjectKey());
CcdiRelationGraphNodeVO toNode = nodeMap.get(edge.getToObjectKey());
if (fromNode != null) {
edge.setFromName(fromNode.getNodeName());
}
if (toNode != null) {
edge.setToName(toNode.getNodeName());
}
}
return List.copyOf(nodeMap.values());
}
private void enrichNode(CcdiRelationGraphNodeVO node, CcdiRelationGraphNodeVO centerNode) {
if (node == null) {
return;
}
node.setNodeKey(NODE_PREFIX + node.getObjectKey());
node.setCanExpand(true);
node.setDepth(node.getObjectKey() != null && node.getObjectKey().equals(centerNode.getObjectKey()) ? 0 : 1);
}
private CcdiRelationGraphQueryDTO normalizeGraphQuery(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = queryDTO == null ? new CcdiRelationGraphQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setObjectKey(normalizeText(query.getObjectKey()));
query.setLimit(normalizeLimit(query.getLimit()));
query.setDepth(1);
return query;
}
private CcdiRelationGraphSuspectedEnterpriseQueryDTO normalizeSuspectedEnterpriseQuery(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseQueryDTO query =
queryDTO == null ? new CcdiRelationGraphSuspectedEnterpriseQueryDTO() : queryDTO;
query.setPersonName(normalizeText(query.getPersonName()));
query.setCertNo(normalizeText(query.getCertNo()));
query.setBirthDate(normalizeText(query.getBirthDate()));
query.setLimit(normalizeSuspectedLimit(query.getLimit()));
return query;
}
private Integer normalizeSuspectedLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_SUSPECTED_LIMIT;
}
return Math.min(limit, MAX_SUSPECTED_LIMIT);
}
private LocalDate resolveBirthDate(CcdiRelationGraphSuspectedEnterpriseQueryDTO query) {
LocalDate explicitBirthDate = parseDate(query.getBirthDate());
if (explicitBirthDate != null) {
return explicitBirthDate;
}
return parseBirthDateFromCertNo(query.getCertNo());
}
private boolean applyAgeRule(CcdiRelationGraphSuspectedEnterpriseItemVO row, LocalDate birthDate) {
LocalDate establishDate = parseDate(row.getEstablishDate());
if (birthDate == null || establishDate == null) {
row.setMatchReason("姓名一致;企业成立日期或出生日期缺失,年龄无法判断");
return true;
}
int age = Period.between(birthDate, establishDate).getYears();
row.setAgeAtEstablish(age);
if (age < 18) {
return false;
}
row.setMatchReason("姓名一致;成立时年龄" + age + "");
return true;
}
private LocalDate parseBirthDateFromCertNo(String certNo) {
if (isBlank(certNo)) {
return null;
}
String value = certNo.trim();
if (value.matches("^\\d{17}[0-9Xx]$")) {
return parseCompactDate(value.substring(6, 14));
}
if (value.matches("^\\d{15}$")) {
return parseCompactDate("19" + value.substring(6, 12));
}
return null;
}
private LocalDate parseCompactDate(String value) {
try {
return LocalDate.parse(value, DateTimeFormatter.BASIC_ISO_DATE);
} catch (DateTimeParseException ignored) {
return null;
}
}
private LocalDate parseDate(String value) {
if (isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim(), DATE_FORMATTER);
} catch (DateTimeParseException ignored) {
return null;
}
}
private Integer normalizeLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private String toObjectKey(String nodeKey) {
if (isBlank(nodeKey)) {
return null;
}
return nodeKey.startsWith(NODE_PREFIX) ? nodeKey.substring(NODE_PREFIX.length()) : nodeKey;
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}

View File

@@ -105,36 +105,123 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="salaryExclusionPredicate"> <sql id="salaryExclusionPredicate">
not ( not (
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司' (
and ( bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
IFNULL(bs.USER_MEMO, '') LIKE '%代发%' and (
or IFNULL(bs.USER_MEMO, '') LIKE '%工资%' IFNULL(bs.USER_MEMO, '') LIKE '%代发%'
or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%' or IFNULL(bs.USER_MEMO, '') LIKE '%工资%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪酬%' or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪%' or IFNULL(bs.USER_MEMO, '') LIKE '%薪%'
or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%' or IFNULL(bs.USER_MEMO, '') LIKE '%薪金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%%' or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%' or IFNULL(bs.USER_MEMO, '') LIKE '%%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年%' or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%'
or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%' or IFNULL(bs.USER_MEMO, '') LIKE '%年金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务费%' or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%' or IFNULL(bs.USER_MEMO, '') LIKE '%劳务%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提成%' or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%' or IFNULL(bs.USER_MEMO, '') LIKE '%提成%'
or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%' or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%'
or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%' or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%'
or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%' or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%'
or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%' or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%' or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%'
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%' or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%' or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%' or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%' or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%' or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
)
)
or (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
)
) )
) )
</sql> </sql>
<sql id="financialProductExclusionPredicate">
not (
(
(
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|理财.*托管|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝'
or IFNULL(bs.USER_MEMO, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|本金划出|本金返还|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝|整存整取|智能存款|通知存款'
or IFNULL(bs.CASH_TYPE, '') REGEXP '受托理财|表内理财|购买理财|理财购买|理财扣款|理财申购|理财认购|结构性存款|存款产品|朝朝宝'
or (
IFNULL(bs.USER_MEMO, '') REGEXP '申购|认购|赎回'
and IFNULL(bs.USER_MEMO, '') REGEXP '理财|产品|存款|本金|余额宝|朝朝宝|朝朝盈'
)
)
and IFNULL(bs.USER_MEMO, '') NOT REGEXP '财务|经理|代理财税'
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') NOT LIKE '%代理财政%'
)
)
</sql>
<sql id="thirdPartyWithdrawIncomePredicate">
(
(
bs.BANK in ('ALIPAY', 'WECHAT')
and (
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
)
)
or (
(
bs.BANK is null
or bs.BANK = ''
or bs.BANK not in ('ALIPAY', 'WECHAT')
)
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
or IFNULL(bs.USER_MEMO, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
or IFNULL(bs.CASH_TYPE, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
)
and (
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现到账%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现到账%'
)
)
)
</sql>
<sql id="abnormalCustomerTransactionSubjectSql">
select
staff.id_card as subjectCertNo,
staff.name as subjectName,
'本人' as subjectType
from ccdi_base_staff staff
union all
select
relation.relation_cert_no as subjectCertNo,
relation.relation_name as subjectName,
case
when relation.relation_type is not null and trim(relation.relation_type) != '' then relation.relation_type
else '关系人'
end as subjectType
from ccdi_staff_fmy_relation relation
where relation.status = 1
and relation.relation_cert_no is not null
and trim(relation.relation_cert_no) != ''
</sql>
<sql id="salaryIncomePredicate"> <sql id="salaryIncomePredicate">
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司' bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and ( and (
@@ -167,6 +254,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局' or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
) )
and <include refid="financialProductExclusionPredicate"/>
and ( and (
exists ( exists (
select 1 select 1
@@ -198,6 +286,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款' IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政' or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政'
) )
and <include refid="financialProductExclusionPredicate"/>
and ( and (
exists ( exists (
select 1 select 1
@@ -235,6 +324,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null and relation.person_id is null
and <include refid="salaryExclusionPredicate"/> and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
</select> </select>
<select id="selectCumulativeIncomeObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectCumulativeIncomeObjects" resultMap="BankTagObjectHitResultMap">
@@ -262,6 +352,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null and relation.person_id is null
and <include refid="salaryExclusionPredicate"/> and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold} having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) t ) t
@@ -283,6 +374,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
group by staff.id_card group by staff.id_card
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold} having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
@@ -302,18 +394,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold} and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
and <include refid="cashDepositPredicate"/> and <include refid="cashDepositPredicate"/>
and ( and <include refid="financialProductExclusionPredicate"/>
exists ( and exists (
select 1 select 1
from ccdi_base_staff staff from ccdi_base_staff staff
where staff.id_card = bs.cret_no where staff.id_card = bs.cret_no
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.relation_cert_no = bs.cret_no
and relation.status = 1
)
) )
</select> </select>
@@ -340,16 +425,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold} and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/> and <include refid="cashDepositPredicate"/>
union all and <include refid="financialProductExclusionPredicate"/>
select
relation.person_id AS object_key,
LEFT(TRIM(bs.TRX_DATE), 10) AS cash_date
from ccdi_bank_statement bs
inner join ccdi_staff_fmy_relation relation on relation.relation_cert_no = bs.cret_no
where bs.project_id = #{projectId}
and relation.status = 1
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/>
) source ) source
group by source.object_key, source.cash_date group by source.object_key, source.cash_date
having COUNT(1) > #{frequencyThreshold} having COUNT(1) > #{frequencyThreshold}
@@ -373,8 +449,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
or IFNULL(bs.USER_MEMO, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入' or IFNULL(bs.USER_MEMO, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
or IFNULL(bs.CASH_TYPE, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入' or IFNULL(bs.CASH_TYPE, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
) )
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%款%'
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and ( and (
exists ( exists (
select 1 select 1
@@ -392,12 +468,130 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
select select
bs.bank_statement_id AS bankStatementId, hit.bankStatementId AS bankStatementId,
bs.group_id AS groupId, max(hit.groupId) AS groupId,
bs.batch_id AS logId, max(hit.logId) AS logId,
'占位SQL待补充真实规则' AS reasonDetail substring_index(
from ccdi_bank_statement bs min(concat(lpad(hit.matchPriority, 2, '0'), '|', hit.reasonDetail)),
where 1 = 0 '|',
-1
) AS reasonDetail
from (
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
1 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与信贷客户账号发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_account_info account
on trim(IFNULL(bs.customer_account_no, '')) != ''
and account.owner_type = 'CREDIT_CUSTOMER'
and account.account_no = bs.customer_account_no
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
2 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介账号发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_account_info account
on trim(IFNULL(bs.customer_account_no, '')) != ''
and account.owner_type = 'INTERMEDIARY'
and account.account_no = bs.customer_account_no
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
3 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介关联企业发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_enterprise_base_info enterprise
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and enterprise.enterprise_name = bs.CUSTOMER_ACCOUNT_NAME
and enterprise.ent_source = 'INTERMEDIARY'
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
4 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生微信/支付宝交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_biz_intermediary intermediary
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and trim(IFNULL(intermediary.name, '')) != ''
and bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
and bs.bank in ('ALIPAY', 'WECHAT')
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
5 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生名称精确匹配交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_biz_intermediary intermediary
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
) hit
group by hit.bankStatementId
</select> </select>
<select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap">
@@ -416,9 +610,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_staff_fmy_relation relation from ccdi_staff_fmy_relation relation
inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no
where relation.status = 1 where relation.status = 1
and relation.annual_income is not null
and ( and (
relation.annual_income is null relation.annual_income = 0
or relation.annual_income = 0
or relation.annual_income / 12 &lt; 3000 or relation.annual_income / 12 &lt; 3000
) )
and bs.project_id = #{projectId} and bs.project_id = #{projectId}
@@ -503,8 +697,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0 and IFNULL(bs.AMOUNT_DR, 0) > 0
and ( and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注' IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注' or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
) )
</select> </select>
@@ -658,6 +852,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0 and IFNULL(bs.AMOUNT_CR, 0) > 0
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; '浙江兰溪农村商业银行股份有限公司' and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; '浙江兰溪农村商业银行股份有限公司'
and not (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
)
)
and ( and (
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees' IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费' or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费'
@@ -1055,6 +1258,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证' or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证' or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
) )
and <include refid="financialProductExclusionPredicate"/>
</select> </select>
<select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap">
@@ -1074,11 +1278,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_bank_statement bs from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) >= 0 and IFNULL(bs.AMOUNT_CR, 0) > 0
and ( and <include refid="thirdPartyWithdrawIncomePredicate"/>
IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
)
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10) group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having COUNT(1) > #{frequencyThreshold} having COUNT(1) > #{frequencyThreshold}
) t ) t
@@ -1087,10 +1288,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '单日微信/支付宝提现到账金额 ', CAST(t.withdrawAmount AS CHAR),
where 1 = 0 ' 元,超过阈值 ', CAST(#{amountThreshold} AS CHAR),
' 元,交易日:', t.transDate
) AS reasonDetail
from (
select
staff.id_card AS objectKey,
LEFT(TRIM(bs.TRX_DATE), 10) AS transDate,
ROUND(SUM(IFNULL(bs.AMOUNT_CR, 0)), 2) AS withdrawAmount
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0
and <include refid="thirdPartyWithdrawIncomePredicate"/>
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{amountThreshold}
) t
</select> </select>
<select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap">
@@ -1322,10 +1538,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold} and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and ( and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管' IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管' or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管' or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
) )
and <include refid="financialProductExclusionPredicate"/>
</select> </select>
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">

View File

@@ -0,0 +1,384 @@
<?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.CcdiFundGraphMapper">
<resultMap id="FundGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO">
<id property="objectKey" column="objectKey"/>
<result property="nodeKey" column="nodeKey"/>
<result property="nodeName" column="nodeName"/>
<result property="idNo" column="idNo"/>
<result property="cinocsno" column="cinocsno"/>
<result property="idnoType" column="idnoType"/>
<result property="staffId" column="staffId"/>
<result property="sourceType" column="sourceType"/>
<result property="nodeType" column="nodeType"/>
<result property="identityType" column="identityType"/>
<result property="relationType" column="relationType"/>
<result property="accountCount" column="accountCount"/>
<result property="createdTime" column="createdTime"/>
<result property="updatedTime" column="updatedTime"/>
<result property="canExpand" column="canExpand"/>
<result property="depth" column="depth"/>
<result property="totalAmount" column="totalAmount"/>
<result property="transactionCount" column="transactionCount"/>
</resultMap>
<resultMap id="FundGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO">
<id property="edgeKey" column="edgeKey"/>
<result property="fromKey" column="fromKey"/>
<result property="toKey" column="toKey"/>
<result property="fromObjectKey" column="fromObjectKey"/>
<result property="toObjectKey" column="toObjectKey"/>
<result property="fromName" column="fromName"/>
<result property="toName" column="toName"/>
<result property="totalAmount" column="totalAmount"/>
<result property="transactionCount" column="transactionCount"/>
<result property="firstTrxDate" column="firstTrxDate"/>
<result property="lastTrxDate" column="lastTrxDate"/>
<result property="direction" column="direction"/>
<result property="familyRelationType" column="familyRelationType"/>
<result property="sourceType" column="sourceType"/>
<result property="relationDesc" column="relationDesc"/>
<result property="sourceDesc" column="sourceDesc"/>
<result property="remark" column="remark"/>
<result property="depth" column="depth"/>
<result property="canTrace" column="canTrace"/>
</resultMap>
<resultMap id="FundGraphStatementResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO">
<id property="bankStatementId" column="bankStatementId"/>
<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="cashType" column="cashType"/>
<result property="userMemo" column="userMemo"/>
<result property="amount" column="amount"/>
<result property="direction" column="direction"/>
<result property="familyRelationType" column="familyRelationType"/>
</resultMap>
<sql id="detailFilter">
<if test="query.transactionStartTime != null and query.transactionStartTime != ''">
AND d.trx_date <![CDATA[ >= ]]> (#{query.transactionStartTime} COLLATE utf8mb4_general_ci)
</if>
<if test="query.transactionEndTime != null and query.transactionEndTime != ''">
AND d.trx_date <![CDATA[ <= ]]>
(CASE
WHEN LENGTH(TRIM(#{query.transactionEndTime})) = 10
THEN CONCAT(TRIM(#{query.transactionEndTime}), ' 23:59:59')
ELSE TRIM(#{query.transactionEndTime})
END COLLATE utf8mb4_general_ci)
</if>
<if test="query.amountMin != null">
AND d.amount <![CDATA[ >= ]]> #{query.amountMin}
</if>
<if test="query.amountMax != null">
AND d.amount <![CDATA[ <= ]]> #{query.amountMax}
</if>
<if test="query.direction != null and query.direction != ''">
AND d.flag = (#{query.direction} COLLATE utf8mb4_general_ci)
</if>
</sql>
<sql id="subjectJoinRows">
SELECT
d.object_key AS detailObjectKey,
d.bank_statement_id AS bankStatementId,
d.trx_date AS trxDate,
d.le_account_no AS leAccountNo,
d.le_account_name AS leAccountName,
d.customer_account_name AS customerAccountName,
d.customer_account_no AS customerAccountNo,
d.cash_type AS cashType,
d.user_memo AS userMemo,
d.amount,
d.flag AS direction,
d.family_relation_type AS familyRelationType,
from_subject.object_key AS fromObjectKey,
to_subject.object_key AS toObjectKey,
CONCAT('idno_node/', from_subject.object_key) AS fromKey,
CONCAT('idno_node/', to_subject.object_key) AS toKey,
from_subject.name AS fromName,
to_subject.name AS toName,
from_subject.idnocfno AS fromIdNo,
to_subject.idnocfno AS toIdNo
FROM lx_fund_flow_detail_edge d
INNER JOIN lx_fund_flow_own_account_edge from_own
ON from_own.to_key = d.from_key
INNER JOIN lx_fund_flow_subject_node from_subject
ON CONCAT('idno_node/', from_subject.object_key) = from_own.from_key
INNER JOIN lx_fund_flow_own_account_edge to_own
ON to_own.to_key = d.to_key
INNER JOIN lx_fund_flow_subject_node to_subject
ON CONCAT('idno_node/', to_subject.object_key) = to_own.from_key
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND (
from_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
OR to_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
)
</if>
<include refid="detailFilter"/>
</sql>
<select id="selectFundGraphSubjects" resultMap="FundGraphNodeResultMap">
SELECT
n.object_key AS objectKey,
CONCAT('idno_node/', n.object_key) AS nodeKey,
n.name AS nodeName,
n.idnocfno AS idNo,
n.cinocsno AS cinocsno,
n.idno_type AS idnoType,
n.staff_id AS staffId,
n.source_type AS sourceType,
CASE
WHEN n.idno_type = 'NAME_PROXY' OR n.source_type LIKE '%COUNTERPARTY%' THEN 'PROXY'
ELSE 'PERSON'
END AS nodeType,
CASE
WHEN n.idnocfno IS NOT NULL AND TRIM(n.idnocfno) != '' THEN 'IDNO'
ELSE 'NAME'
END AS identityType,
CASE
WHEN n.source_type LIKE 'GRAPH_TEST_FAMILY_%' THEN REPLACE(n.source_type, 'GRAPH_TEST_FAMILY_', '')
ELSE NULL
END AS relationType,
CASE
WHEN EXISTS (
SELECT 1
FROM lx_fund_flow_own_account_edge own
WHERE own.from_key = CONCAT('idno_node/', n.object_key)
) THEN 1
ELSE 0
END AS canExpand,
(
SELECT COUNT(1)
FROM lx_fund_flow_own_account_edge own_count
WHERE own_count.from_key = CONCAT('idno_node/', n.object_key)
) AS accountCount,
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
0 AS depth,
0 AS totalAmount,
0 AS transactionCount
FROM lx_fund_flow_subject_node n
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
</if>
<if test="query.objectKey == null or query.objectKey == ''">
<if test="query.keyword != null and query.keyword != ''">
AND (
n.idnocfno = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
OR n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
)
</if>
</if>
ORDER BY
CASE
WHEN n.idnocfno = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
WHEN n.staff_id IS NOT NULL AND TRIM(n.staff_id) != '' THEN 2
WHEN UPPER(IFNULL(n.source_type, '')) LIKE '%EMPLOYEE%' THEN 2
WHEN n.source_type LIKE '%员工%' THEN 2
ELSE 3
END,
n.name
LIMIT
<choose>
<when test="query.limit != null and query.limit > 0">
#{query.limit}
</when>
<otherwise>
20
</otherwise>
</choose>
</select>
<select id="selectFundGraphEdges" resultMap="FundGraphEdgeResultMap">
SELECT
MD5(CONCAT(graph_rows.fromKey, '|', graph_rows.toKey, '|', graph_rows.direction, '|', IFNULL(graph_rows.familyRelationType, ''))) AS edgeKey,
graph_rows.fromKey,
graph_rows.toKey,
graph_rows.fromObjectKey,
graph_rows.toObjectKey,
MAX(graph_rows.fromName) AS fromName,
MAX(graph_rows.toName) AS toName,
SUM(graph_rows.amount) AS totalAmount,
COUNT(1) AS transactionCount,
MIN(graph_rows.trxDate) AS firstTrxDate,
MAX(graph_rows.trxDate) AS lastTrxDate,
graph_rows.direction,
graph_rows.familyRelationType,
'BANK' AS sourceType,
NULL AS relationDesc,
NULL AS sourceDesc,
NULL AS remark,
1 AS depth,
CASE
WHEN MAX(
CASE
WHEN graph_rows.fromObjectKey = #{query.objectKey} THEN
CASE
WHEN graph_rows.toIdNo IS NOT NULL AND TRIM(graph_rows.toIdNo) != '' THEN 1
ELSE 0
END
ELSE
CASE
WHEN graph_rows.fromIdNo IS NOT NULL AND TRIM(graph_rows.fromIdNo) != '' THEN 1
ELSE 0
END
END
) = 1 THEN 1
WHEN EXISTS (
SELECT 1
FROM lx_fund_flow_own_account_edge own
WHERE own.from_key = CASE
WHEN graph_rows.fromObjectKey = #{query.objectKey}
THEN graph_rows.toKey
ELSE graph_rows.fromKey
END
) THEN 1
ELSE 0
END AS canTrace
FROM (
<include refid="subjectJoinRows"/>
) graph_rows
WHERE 1 = 1
GROUP BY
graph_rows.fromKey,
graph_rows.toKey,
graph_rows.fromObjectKey,
graph_rows.toObjectKey,
graph_rows.direction,
graph_rows.familyRelationType
<if test="query.minTotalAmount != null">
HAVING SUM(graph_rows.amount) <![CDATA[ >= ]]> #{query.minTotalAmount}
</if>
ORDER BY totalAmount DESC, transactionCount DESC
LIMIT #{query.limit}
</select>
<select id="selectFundGraphManualEdges" resultMap="FundGraphEdgeResultMap">
SELECT
m.object_key AS edgeKey,
CONCAT('idno_node/', m.from_object_key) AS fromKey,
CONCAT('idno_node/', m.to_object_key) AS toKey,
m.from_object_key AS fromObjectKey,
m.to_object_key AS toObjectKey,
COALESCE(from_subject.name, m.from_name) AS fromName,
COALESCE(to_subject.name, m.to_name) AS toName,
m.amount AS totalAmount,
m.transaction_count AS transactionCount,
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS firstTrxDate,
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS lastTrxDate,
m.direction,
NULL AS familyRelationType,
m.source_type AS sourceType,
m.relation_desc AS relationDesc,
m.source_desc AS sourceDesc,
m.remark,
1 AS depth,
0 AS canTrace
FROM lx_fund_flow_manual_edge m
LEFT JOIN lx_fund_flow_subject_node from_subject
ON from_subject.object_key = m.from_object_key
LEFT JOIN lx_fund_flow_subject_node to_subject
ON to_subject.object_key = m.to_object_key
WHERE m.source_type = 'MANUAL'
AND (
m.from_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
OR m.to_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
)
ORDER BY m.updated_time DESC
LIMIT #{query.limit}
</select>
<select id="selectFundGraphEdgeDetails" resultMap="FundGraphStatementResultMap">
SELECT
graph_rows.bankStatementId,
graph_rows.trxDate,
graph_rows.leAccountNo,
graph_rows.leAccountName,
graph_rows.customerAccountName,
graph_rows.customerAccountNo,
graph_rows.cashType,
graph_rows.userMemo,
graph_rows.amount,
graph_rows.direction,
graph_rows.familyRelationType
FROM (
<include refid="subjectJoinRows"/>
) graph_rows
WHERE graph_rows.fromKey = (#{query.fromKey} COLLATE utf8mb4_general_ci)
AND graph_rows.toKey = (#{query.toKey} COLLATE utf8mb4_general_ci)
<if test="query.direction != null and query.direction != ''">
AND graph_rows.direction = (#{query.direction} COLLATE utf8mb4_general_ci)
</if>
ORDER BY graph_rows.trxDate DESC, graph_rows.bankStatementId DESC
</select>
<select id="countSubjectByObjectKey" resultType="int">
SELECT COUNT(1)
FROM lx_fund_flow_subject_node
WHERE object_key = (#{objectKey} COLLATE utf8mb4_general_ci)
</select>
<insert id="insertManualSubject">
INSERT IGNORE INTO lx_fund_flow_subject_node (
object_key,
idnocfno,
name,
idno_type,
source_type
) VALUES (
#{objectKey},
#{idNo},
#{name},
CASE
WHEN #{idNo} IS NULL OR TRIM(#{idNo}) = '' THEN 'NAME_PROXY'
ELSE 'PERSON'
END,
'MANUAL'
)
</insert>
<insert id="insertManualEdge">
INSERT INTO lx_fund_flow_manual_edge (
object_key,
from_object_key,
to_object_key,
from_name,
to_name,
amount,
transaction_count,
direction,
relation_desc,
source_desc,
remark,
source_type,
created_by,
updated_by
) VALUES (
#{objectKey},
#{dto.fromObjectKey},
#{dto.toObjectKey},
#{dto.fromName},
#{dto.toName},
#{dto.amount},
#{dto.transactionCount},
#{dto.direction},
#{dto.relationDesc},
#{dto.sourceDesc},
#{dto.remark},
'MANUAL',
#{operator},
#{operator}
)
</insert>
</mapper>

View File

@@ -46,6 +46,7 @@
<result property="displayAmount" column="displayAmount"/> <result property="displayAmount" column="displayAmount"/>
<result property="hasModelRuleHit" column="hasModelRuleHit"/> <result property="hasModelRuleHit" column="hasModelRuleHit"/>
<result property="hasNameListHit" column="hasNameListHit"/> <result property="hasNameListHit" column="hasNameListHit"/>
<result property="nameListHitType" column="nameListHitType"/>
</resultMap> </resultMap>
<resultMap id="AbnormalAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO"> <resultMap id="AbnormalAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO">
@@ -57,6 +58,40 @@
<result property="status" column="status"/> <result property="status" column="status"/>
</resultMap> </resultMap>
<resultMap id="ReportUploadSubjectResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO">
<result property="subjectName" column="subjectName"/>
<result property="accountNos" column="accountNos"/>
<result property="minTrxDate" column="minTrxDate"/>
<result property="maxTrxDate" column="maxTrxDate"/>
<result property="fileCount" column="fileCount"/>
</resultMap>
<resultMap id="ReportModelSummaryResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO">
<result property="modelCode" column="modelCode"/>
<result property="modelName" column="modelName"/>
<result property="warningCount" column="warningCount"/>
<result property="peopleCount" column="peopleCount"/>
<result property="peopleNames" column="peopleNames"/>
</resultMap>
<resultMap id="ReportSuspiciousTransactionResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO">
<id property="bankStatementId" column="bankStatementId"/>
<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="relatedStaffName" column="relatedStaffName"/>
<result property="relatedStaffCode" column="relatedStaffCode"/>
<result property="userMemo" column="userMemo"/>
<result property="cashType" column="cashType"/>
<result property="hitTags" column="hitTags"/>
<result property="displayAmount" column="displayAmount"/>
</resultMap>
<sql id="digitTableSql"> <sql id="digitTableSql">
select 0 as digit select 0 as digit
union all select 1 union all select 1
@@ -338,6 +373,87 @@
order by warning_count desc, model_code asc order by warning_count desc, model_code asc
</select> </select>
<select id="selectReportUploadSubjects" resultMap="ReportUploadSubjectResultMap">
select
trim(bs.LE_ACCOUNT_NAME) as subjectName,
group_concat(distinct trim(bs.LE_ACCOUNT_NO) order by trim(bs.LE_ACCOUNT_NO) separator '、') as accountNos,
date_format(min(
case
when bs.TRX_DATE is null or trim(bs.TRX_DATE) = '' then null
when length(trim(bs.TRX_DATE)) = 10 then str_to_date(concat(trim(bs.TRX_DATE), ' 00:00:00'), '%Y-%m-%d %H:%i:%s')
else str_to_date(trim(bs.TRX_DATE), '%Y-%m-%d %H:%i:%s')
end
), '%Y-%m-%d') as minTrxDate,
date_format(max(
case
when bs.TRX_DATE is null or trim(bs.TRX_DATE) = '' then null
when length(trim(bs.TRX_DATE)) = 10 then str_to_date(concat(trim(bs.TRX_DATE), ' 00:00:00'), '%Y-%m-%d %H:%i:%s')
else str_to_date(trim(bs.TRX_DATE), '%Y-%m-%d %H:%i:%s')
end
), '%Y-%m-%d') as maxTrxDate,
case
when count(distinct fur.id) > 0 then count(distinct fur.id)
else count(distinct bs.batch_id)
end as fileCount
from ccdi_bank_statement bs
left join ccdi_file_upload_record fur
on fur.project_id = bs.project_id
and fur.log_id = bs.batch_id
where bs.project_id = #{projectId}
and bs.LE_ACCOUNT_NAME is not null
and trim(bs.LE_ACCOUNT_NAME) != ''
group by trim(bs.LE_ACCOUNT_NAME)
order by trim(bs.LE_ACCOUNT_NAME) asc
</select>
<select id="selectReportRiskModelSummaries" resultMap="ReportModelSummaryResultMap">
select
model_scope.modelCode,
max(model_scope.modelName) as modelName,
sum(model_scope.warningCount) as warningCount,
count(distinct model_scope.staffIdCard) as peopleCount,
group_concat(distinct model_scope.staffName order by model_scope.staffName separator '、') as peopleNames
from (
select
result.staff_id_card as staffIdCard,
result.staff_name as staffName,
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelCode'))) as modelCode,
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelName'))) as modelName,
cast(json_unquote(json_extract(
result.model_hit_summary_json,
concat('$[', idx.idx, '].warningCount')
)) as unsigned) as warningCount
from ccdi_project_overview_employee_result result
join (
<include refid="jsonArrayIndexSql"/>
) idx on idx.idx &lt; json_length(result.model_hit_summary_json)
where result.project_id = #{projectId}
) model_scope
where model_scope.modelCode is not null
group by model_scope.modelCode
order by warningCount desc, model_scope.modelCode asc
</select>
<select id="selectReportRiskPeople" resultMap="RiskModelPeopleItemResultMap">
select
result.project_id,
result.staff_id_card,
result.staff_name,
result.staff_code,
result.dept_name as department,
null as selected_model_codes
from ccdi_project_overview_employee_result result
where result.project_id = #{projectId}
order by case result.risk_level_code
when 'HIGH' then 1
when 'MEDIUM' then 2
else 3
end,
result.model_count desc,
result.rule_count desc,
result.staff_id_card asc
</select>
<select id="selectRiskModelPeoplePage" resultMap="RiskModelPeopleItemResultMap"> <select id="selectRiskModelPeoplePage" resultMap="RiskModelPeopleItemResultMap">
<bind name="projectId" value="query.projectId"/> <bind name="projectId" value="query.projectId"/>
select select
@@ -415,57 +531,70 @@
from ccdi_bank_statement_tag_result tr from ccdi_bank_statement_tag_result tr
where tr.project_id = #{query.projectId} where tr.project_id = #{query.projectId}
and tr.bank_statement_id is not null and tr.bank_statement_id is not null
and tr.rule_name like '%可疑%'
</sql> </sql>
<sql id="suspiciousTransactionNameHitSql"> <sql id="suspiciousTransactionNameHitSql">
select select
hits.bankStatementId, hits.bankStatementId,
hits.suspiciousPersonName, hits.suspiciousPersonName,
hits.matchPriority hits.matchPriority,
hits.nameListHitType
from ( from (
select select
bs.bank_statement_id as bankStatementId, bs.bank_statement_id as bankStatementId,
intermediary.name as suspiciousPersonName, coalesce(credit_customer.name, account.account_name, '信贷客户账号') as suspiciousPersonName,
1 as matchPriority 1 as matchPriority,
'信贷客户' as nameListHitType
from ccdi_bank_statement bs from ccdi_bank_statement bs
inner join ccdi_biz_intermediary intermediary inner join ccdi_account_info account
on trim(bs.customer_cert_no) != '' on trim(bs.customer_account_no) != ''
and intermediary.person_id = bs.customer_cert_no and account.owner_type = 'CREDIT_CUSTOMER'
and account.account_no = bs.customer_account_no
left join ccdi_credit_customer_base credit_customer
on credit_customer.person_id = account.owner_id
where bs.project_id = #{query.projectId} where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all union all
select select
bs.bank_statement_id as bankStatementId, bs.bank_statement_id as bankStatementId,
enterprise.enterprise_name as suspiciousPersonName, coalesce(intermediary.name, enterprise.enterprise_name, account.account_name, '中介账号') as suspiciousPersonName,
2 as matchPriority 2 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs from ccdi_bank_statement bs
inner join ccdi_enterprise_base_info enterprise inner join ccdi_account_info account
on trim(bs.customer_social_credit_code) != '' on trim(bs.customer_account_no) != ''
and enterprise.social_credit_code = bs.customer_social_credit_code and account.owner_type = 'INTERMEDIARY'
and enterprise.risk_level = '1' and account.account_no = bs.customer_account_no
and enterprise.ent_source = 'INTERMEDIARY' left join ccdi_biz_intermediary intermediary
on intermediary.person_id = account.owner_id
left join ccdi_enterprise_base_info enterprise
on enterprise.social_credit_code = account.owner_id
where bs.project_id = #{query.projectId} where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all union all
select select
bs.bank_statement_id as bankStatementId, bs.bank_statement_id as bankStatementId,
intermediary.name as suspiciousPersonName, intermediary.name as suspiciousPersonName,
3 as matchPriority 3 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs from ccdi_bank_statement bs
inner join ccdi_biz_intermediary intermediary inner join ccdi_biz_intermediary intermediary
on trim(bs.CUSTOMER_ACCOUNT_NAME) != '' on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
where bs.project_id = #{query.projectId} where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all union all
select select
bs.bank_statement_id as bankStatementId, bs.bank_statement_id as bankStatementId,
enterprise.enterprise_name as suspiciousPersonName, enterprise.enterprise_name as suspiciousPersonName,
3 as matchPriority 4 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs from ccdi_bank_statement bs
inner join ccdi_enterprise_base_info enterprise inner join ccdi_enterprise_base_info enterprise
on trim(bs.CUSTOMER_ACCOUNT_NAME) != '' on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
@@ -473,6 +602,7 @@
and enterprise.risk_level = '1' and enterprise.risk_level = '1'
and enterprise.ent_source = 'INTERMEDIARY' and enterprise.ent_source = 'INTERMEDIARY'
where bs.project_id = #{query.projectId} where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
) hits ) hits
</sql> </sql>
@@ -490,7 +620,8 @@
1 as hasModelRuleHit, 1 as hasModelRuleHit,
0 as hasNameListHit, 0 as hasNameListHit,
null as suspiciousPersonName, null as suspiciousPersonName,
null as matchPriority null as matchPriority,
null as nameListHitType
from ( from (
<include refid="suspiciousTransactionBaseSql"/> <include refid="suspiciousTransactionBaseSql"/>
) base ) base
@@ -513,7 +644,8 @@
0 as hasModelRuleHit, 0 as hasModelRuleHit,
1 as hasNameListHit, 1 as hasNameListHit,
name_hits.suspiciousPersonName, name_hits.suspiciousPersonName,
name_hits.matchPriority name_hits.matchPriority,
name_hits.nameListHitType
from ( from (
<include refid="suspiciousTransactionBaseSql"/> <include refid="suspiciousTransactionBaseSql"/>
) base ) base
@@ -548,7 +680,18 @@
max(merged.cashType) as cashType, max(merged.cashType) as cashType,
max(merged.displayAmount) as displayAmount, max(merged.displayAmount) as displayAmount,
max(merged.hasModelRuleHit) as hasModelRuleHit, max(merged.hasModelRuleHit) as hasModelRuleHit,
max(merged.hasNameListHit) as hasNameListHit max(merged.hasNameListHit) as hasNameListHit,
substring_index(
min(
case
when merged.nameListHitType is not null and merged.nameListHitType != ''
then concat(lpad(merged.matchPriority, 2, '0'), '|', merged.nameListHitType)
else null
end
),
'|',
-1
) as nameListHitType
from ( from (
<include refid="suspiciousTransactionMergedSql"/> <include refid="suspiciousTransactionMergedSql"/>
) merged ) merged
@@ -570,7 +713,7 @@
</sql> </sql>
<select id="selectSuspiciousTransactionPage" resultMap="SuspiciousTransactionItemResultMap"> <select id="selectSuspiciousTransactionPage" resultMap="SuspiciousTransactionItemResultMap">
<!-- rule_name like '%可疑%' --> <!-- ccdi_bank_statement_tag_result -->
<!-- ccdi_biz_intermediary --> <!-- ccdi_biz_intermediary -->
<!-- ccdi_enterprise_base_info --> <!-- ccdi_enterprise_base_info -->
<!-- group by merged.bankStatementId --> <!-- group by merged.bankStatementId -->
@@ -586,7 +729,8 @@
final_result.cashType, final_result.cashType,
final_result.displayAmount, final_result.displayAmount,
final_result.hasModelRuleHit, final_result.hasModelRuleHit,
final_result.hasNameListHit final_result.hasNameListHit,
final_result.nameListHitType
from ( from (
<include refid="suspiciousTransactionAggregatedSql"/> <include refid="suspiciousTransactionAggregatedSql"/>
) final_result ) final_result
@@ -607,7 +751,8 @@
final_result.cashType, final_result.cashType,
final_result.displayAmount, final_result.displayAmount,
final_result.hasModelRuleHit, final_result.hasModelRuleHit,
final_result.hasNameListHit final_result.hasNameListHit,
final_result.nameListHitType
from ( from (
<include refid="suspiciousTransactionAggregatedSql"/> <include refid="suspiciousTransactionAggregatedSql"/>
) final_result ) final_result
@@ -615,6 +760,52 @@
order by final_result.trxDate desc, final_result.bankStatementId desc order by final_result.trxDate desc, final_result.bankStatementId desc
</select> </select>
<select id="selectReportSuspiciousTransactionList" resultMap="ReportSuspiciousTransactionResultMap">
select
final_result.bankStatementId,
final_result.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,
final_result.relatedStaffName,
final_result.relatedStaffCode,
final_result.userMemo,
final_result.cashType,
case
when final_result.nameListHitType = '中介' then
replace(
replace(ifnull(tag_result.hitTags, ''), '与客户之间非正常资金往来', '疑似与中介往来'),
'异常交易',
'疑似与中介往来'
)
when final_result.nameListHitType = '信贷客户' then
replace(
replace(ifnull(tag_result.hitTags, ''), '与客户之间非正常资金往来', '与信贷客户之间非正常资金往来'),
'异常交易',
'与信贷客户之间非正常资金往来'
)
else tag_result.hitTags
end as hitTags,
final_result.displayAmount
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
left join (
select
tr.bank_statement_id,
group_concat(distinct tr.rule_name order by tr.id separator '、') as hitTags
from ccdi_bank_statement_tag_result tr
where tr.project_id = #{query.projectId}
and tr.bank_statement_id is not null
group by tr.bank_statement_id
) tag_result on tag_result.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc
</select>
<select id="selectEmployeeCreditNegativePage" <select id="selectEmployeeCreditNegativePage"
resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO"> resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO">
select select

View File

@@ -0,0 +1,330 @@
<?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.CcdiRelationGraphMapper">
<resultMap id="RelationGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO">
<id property="objectKey" column="objectKey"/>
<result property="nodeKey" column="nodeKey"/>
<result property="nodeName" column="nodeName"/>
<result property="idNumber" column="idNumber"/>
<result property="subjectType" column="subjectType"/>
<result property="sourceType" column="sourceType"/>
<result property="createdTime" column="createdTime"/>
<result property="updatedTime" column="updatedTime"/>
<result property="canExpand" column="canExpand"/>
<result property="depth" column="depth"/>
</resultMap>
<resultMap id="RelationGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO">
<id property="objectKey" column="objectKey"/>
<result property="fromKey" column="fromKey"/>
<result property="toKey" column="toKey"/>
<result property="edgeTable" column="edgeTable"/>
<result property="relationType" column="relationType"/>
<result property="companyName" column="companyName"/>
<result property="stockName" column="stockName"/>
<result property="stockType" column="stockType"/>
<result property="stockPercent" column="stockPercent"/>
<result property="shouldCapi" column="shouldCapi"/>
<result property="shouldCapiValue" column="shouldCapiValue"/>
<result property="shouldCapiUnit" column="shouldCapiUnit"/>
<result property="shoudDate" column="shoudDate"/>
<result property="pKeyNo" column="pKeyNo"/>
<result property="operName" column="operName"/>
<result property="operKeyNo" column="operKeyNo"/>
<result property="personId" column="personId"/>
<result property="relationName" column="relationName"/>
<result property="relationCertNo" column="relationCertNo"/>
<result property="gender" column="gender"/>
<result property="birthDate" column="birthDate"/>
<result property="relationCertType" column="relationCertType"/>
<result property="mobilePhone1" column="mobilePhone1"/>
<result property="mobilePhone2" column="mobilePhone2"/>
<result property="wechatNo1" column="wechatNo1"/>
<result property="wechatNo2" column="wechatNo2"/>
<result property="wechatNo3" column="wechatNo3"/>
<result property="contactAddress" column="contactAddress"/>
<result property="annualIncome" column="annualIncome"/>
<result property="relationDesc" column="relationDesc"/>
<result property="status" column="status"/>
<result property="effectiveDate" column="effectiveDate"/>
<result property="invalidDate" column="invalidDate"/>
<result property="remark" column="remark"/>
<result property="dataSource" column="dataSource"/>
</resultMap>
<resultMap id="SuspectedEnterpriseResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO">
<result property="candidateKeyNo" column="candidateKeyNo"/>
<result property="personName" column="personName"/>
<result property="companyId" column="companyId"/>
<result property="companyName" column="companyName"/>
<result property="creditCode" column="creditCode"/>
<result property="enterpriseStatus" column="enterpriseStatus"/>
<result property="industryName" column="industryName"/>
<result property="relationType" column="relationType"/>
<result property="stockPercent" column="stockPercent"/>
<result property="establishDate" column="establishDate"/>
</resultMap>
<sql id="nodeColumns">
n.object_key AS objectKey,
CONCAT('rel_node/', n.object_key) AS nodeKey,
n.node_name AS nodeName,
n.id_number AS idNumber,
n.subject_type AS subjectType,
n.source_type AS sourceType,
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
1 AS canExpand,
0 AS depth
</sql>
<select id="selectRelationGraphSubjects" resultMap="RelationGraphNodeResultMap">
SELECT
<include refid="nodeColumns"/>
FROM lx_rel_node n
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
</if>
<if test="query.objectKey == null or query.objectKey == ''">
<if test="query.keyword != null and query.keyword != ''">
AND (
n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.id_number = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.node_name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
)
</if>
</if>
ORDER BY
CASE
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
WHEN n.id_number = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
ELSE 2
END,
n.node_name
LIMIT 20
</select>
<select id="selectRelationGraphNodesByKeys" resultMap="RelationGraphNodeResultMap">
SELECT
<include refid="nodeColumns"/>
FROM lx_rel_node n
WHERE n.object_key IN
<foreach collection="objectKeys" item="objectKey" open="(" separator="," close=")">
#{objectKey}
</foreach>
</select>
<select id="selectRelationGraphEdges" resultMap="RelationGraphEdgeResultMap">
SELECT *
FROM (
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_family_edge' AS edgeTable,
e.relation_type AS relationType,
NULL AS companyName,
NULL AS stockName,
NULL AS stockType,
NULL AS stockPercent,
NULL AS shouldCapi,
NULL AS shouldCapiValue,
NULL AS shouldCapiUnit,
NULL AS shoudDate,
NULL AS pKeyNo,
NULL AS operName,
NULL AS operKeyNo,
e.person_id AS personId,
e.relation_name AS relationName,
e.relation_cert_no AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
e.relation_desc AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_family_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
UNION ALL
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_stock_edge' AS edgeTable,
e.stock_type AS relationType,
e.company_name AS companyName,
e.stock_name AS stockName,
e.stock_type AS stockType,
e.stock_percent AS stockPercent,
e.should_capi AS shouldCapi,
e.should_capi_value AS shouldCapiValue,
e.should_capi_unit AS shouldCapiUnit,
e.shoud_date AS shoudDate,
e.p_key_no AS pKeyNo,
NULL AS operName,
NULL AS operKeyNo,
NULL AS personId,
NULL AS relationName,
NULL AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
NULL AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_stock_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
UNION ALL
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_represent_edge' AS edgeTable,
'法定代表人' AS relationType,
NULL AS companyName,
NULL AS stockName,
NULL AS stockType,
NULL AS stockPercent,
NULL AS shouldCapi,
NULL AS shouldCapiValue,
NULL AS shouldCapiUnit,
NULL AS shoudDate,
NULL AS pKeyNo,
e.oper_name AS operName,
e.oper_key_no AS operKeyNo,
NULL AS personId,
NULL AS relationName,
NULL AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
NULL AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_represent_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
) graph_edges
ORDER BY
CASE edgeTable
WHEN 'lx_rel_family_edge' THEN 0
WHEN 'lx_rel_stock_edge' THEN 1
ELSE 2
END,
relationType,
objectKey
LIMIT #{query.limit}
</select>
<select id="countSuspectedEnterpriseKeyNos" resultType="int">
SELECT COUNT(DISTINCT candidate_key_no)
FROM (
SELECT e.oper_key_no AS candidate_key_no
FROM lx_rel_represent_edge e
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.oper_key_no IS NOT NULL
AND e.oper_key_no != ''
UNION ALL
SELECT e.p_key_no AS candidate_key_no
FROM lx_rel_stock_edge e
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.stock_type = '自然人股东'
AND e.p_key_no IS NOT NULL
AND e.p_key_no != ''
) same_name_candidates
</select>
<select id="selectSuspectedEnterprises" resultMap="SuspectedEnterpriseResultMap">
SELECT *
FROM (
SELECT
e.oper_key_no AS candidateKeyNo,
e.oper_name AS personName,
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
COALESCE(ent.enterprise_name, company_node.node_name) AS companyName,
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
ent.status AS enterpriseStatus,
ent.industry_name AS industryName,
'法定代表人' AS relationType,
NULL AS stockPercent,
DATE_FORMAT(ent.establish_date, '%Y-%m-%d') AS establishDate,
0 AS relationSort
FROM lx_rel_represent_edge e
LEFT JOIN lx_rel_node company_node
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
LEFT JOIN ccdi_enterprise_base_info ent
ON ent.social_credit_code = company_node.id_number
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.oper_key_no IS NOT NULL
AND e.oper_key_no != ''
UNION ALL
SELECT
e.p_key_no AS candidateKeyNo,
e.stock_name AS personName,
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
COALESCE(ent.enterprise_name, e.company_name, company_node.node_name) AS companyName,
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
ent.status AS enterpriseStatus,
ent.industry_name AS industryName,
e.stock_type AS relationType,
e.stock_percent AS stockPercent,
COALESCE(DATE_FORMAT(ent.establish_date, '%Y-%m-%d'), e.shoud_date) AS establishDate,
1 AS relationSort
FROM lx_rel_stock_edge e
LEFT JOIN lx_rel_node company_node
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
LEFT JOIN ccdi_enterprise_base_info ent
ON ent.social_credit_code = company_node.id_number
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.stock_type = '自然人股东'
AND e.p_key_no IS NOT NULL
AND e.p_key_no != ''
) suspected_enterprises
ORDER BY relationSort, companyName, companyId
LIMIT #{limit}
</select>
</mapper>

View File

@@ -13,6 +13,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -181,6 +182,27 @@ class CcdiProjectOverviewControllerContractTest {
assertNotNull(operation); assertNotNull(operation);
} }
@Test
void shouldExposeOverviewReportExportEndpointContract() throws Exception {
Class<?> controllerClass = Class.forName("com.ruoyi.ccdi.project.controller.CcdiProjectOverviewController");
Method method = controllerClass.getMethod(
"exportOverviewReport",
HttpServletResponse.class,
Long.class
);
RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
Operation operation = method.getAnnotation(Operation.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
assertNotNull(requestMapping);
assertEquals("/report/export", requestMapping.value()[0]);
assertEquals(List.of(RequestMethod.GET, RequestMethod.POST), Arrays.asList(requestMapping.method()));
assertNotNull(operation);
assertEquals("一键导出结果总览报告", operation.summary());
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
}
@Test @Test
void shouldExposeSuspiciousTransactionsQueryDtoFields() throws Exception { void shouldExposeSuspiciousTransactionsQueryDtoFields() throws Exception {
Class<?> dtoClass = Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO"); Class<?> dtoClass = Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO");

View File

@@ -100,7 +100,7 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception { void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE); String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则")); assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(6, countMatches(xml, "where 1 = 0")); assertEquals(5, countMatches(xml, "where 1 = 0"));
} }
@Test @Test
@@ -116,6 +116,31 @@ class CcdiBankTagAnalysisMapperXmlTest {
} }
} }
@Test
void lowIncomeRelativeRule_shouldIgnoreNullAnnualIncome() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectLowIncomeRelativeLargeTransactionObjects");
assertTrue(selectSql.contains("relation.annual_income is not null"));
assertTrue(!selectSql.contains("relation.annual_income is null"));
}
@Test
void abnormalCustomerTransactionRule_shouldUseCreditCustomerAndIntermediaryAccountRules() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectAbnormalCustomerTransactionStatements");
assertTrue(selectSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"));
assertTrue(selectSql.contains("account.owner_type = 'INTERMEDIARY'"));
assertTrue(selectSql.contains("account.account_no = bs.customer_account_no"));
assertTrue(selectSql.contains("enterprise.ent_source = 'INTERMEDIARY'"));
assertTrue(selectSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"));
assertTrue(selectSql.contains("bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')"));
assertTrue(selectSql.contains("bs.bank in ('ALIPAY', 'WECHAT')"));
assertEquals(5, countMatches(selectSql, "GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"));
assertTrue(!selectSql.contains("customer_cert_no"));
assertTrue(!selectSql.contains("social_credit_code = bs"));
}
@Test @Test
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception { void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE); String xml = readXml(RESOURCE);

View File

@@ -90,13 +90,50 @@ class CcdiProjectOverviewMapperSqlTest {
void shouldExposeSuspiciousTransactionAggregationQuery() throws Exception { void shouldExposeSuspiciousTransactionAggregationQuery() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml")); String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage"); String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage");
String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
assertTrue(suspiciousSql.contains("rule_name like '%可疑%'"), suspiciousSql); assertTrue(modelHitSql.contains("from ccdi_bank_statement_tag_result tr"), modelHitSql);
assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql);
assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql);
assertFalse(modelHitSql.contains("ABNORMAL_CUSTOMER_TRANSACTION"), modelHitSql);
assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql); assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql);
assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql); assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql);
assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql); assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql);
assertTrue(aggregatedSql.contains("lpad(merged.matchPriority, 2, '0')"), aggregatedSql);
assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql); assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql); assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("final_result.nameListHitType"), suspiciousSql);
String reportSuspiciousSql = extractSelect(xml, "selectReportSuspiciousTransactionList");
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '中介'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
}
@Test
void suspiciousTransactionNameListSql_shouldKeepCreditCustomerAndIntermediaryRulesScopedByAmount() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String nameHitSql = extractSqlFragment(xml, "suspiciousTransactionNameHitSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
assertTrue(nameHitSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"), nameHitSql);
assertTrue(nameHitSql.contains("account.owner_type = 'INTERMEDIARY'"), nameHitSql);
assertTrue(nameHitSql.contains("account.account_no = bs.customer_account_no"), nameHitSql);
assertTrue(nameHitSql.contains("'信贷客户' as nameListHitType"), nameHitSql);
assertTrue(nameHitSql.contains("'中介' as nameListHitType"), nameHitSql);
assertTrue(nameHitSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"), nameHitSql);
assertTrue(nameHitSql.contains("enterprise.ent_source = 'INTERMEDIARY'"), nameHitSql);
assertTrue(
nameHitSql.contains("GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"),
nameHitSql
);
assertFalse(nameHitSql.contains("customer_cert_no"), nameHitSql);
assertFalse(nameHitSql.contains("social_credit_code = bs"), nameHitSql);
assertTrue(aggregatedSql.contains("group by merged.bankStatementId"), aggregatedSql);
assertTrue(aggregatedSql.contains("max(merged.hasModelRuleHit) as hasModelRuleHit"), aggregatedSql);
assertTrue(aggregatedSql.contains("max(merged.hasNameListHit) as hasNameListHit"), aggregatedSql);
} }
@Test @Test
@@ -159,4 +196,13 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(endIndex >= 0, "missing closing select tag: " + selectId); assertTrue(endIndex >= 0, "missing closing select tag: " + selectId);
return xml.substring(startIndex, endIndex); return xml.substring(startIndex, endIndex);
} }
private String extractSqlFragment(String xml, String sqlId) {
String start = "<sql id=\"" + sqlId + "\"";
int startIndex = xml.indexOf(start);
assertTrue(startIndex >= 0, "missing sql fragment: " + sqlId);
int endIndex = xml.indexOf("</sql>", startIndex);
assertTrue(endIndex >= 0, "missing closing sql tag: " + sqlId);
return xml.substring(startIndex, endIndex);
}
} }

View File

@@ -0,0 +1,169 @@
package com.ruoyi.ccdi.project.service.impl;
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.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewReportPdfExporterTest {
@Test
void shouldExportOverviewReportPdf() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter();
MockHttpServletResponse response = new MockHttpServletResponse();
exporter.export(response, buildReport());
assertEquals("application/pdf", response.getContentType());
String header = new String(response.getContentAsByteArray(), 0, 4, StandardCharsets.ISO_8859_1);
assertEquals("%PDF", header);
assertTrue(response.getContentAsByteArray().length > 1000);
}
@Test
void tableGap_shouldLeaveEnoughSpaceForNextSectionTitle() throws Exception {
Class<?> writerClass = Class.forName(
"com.ruoyi.ccdi.project.service.impl.CcdiProjectOverviewReportPdfExporter$PdfPageWriter"
);
float tableAfterGap = readPrivateFloat(writerClass, "TABLE_AFTER_GAP");
float sectionFontSize = readPrivateFloat(writerClass, "SECTION_FONT_SIZE");
assertTrue(tableAfterGap > sectionFontSize);
}
private CcdiProjectOverviewReportVO buildReport() {
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setProjectName("导入测试");
report.setProject(project);
report.setDashboard(buildDashboard());
report.setUploadSubjects(List.of(buildUploadSubject()));
report.setParams(List.of(buildParam()));
report.setModelSummaries(List.of(buildModelSummary()));
report.setRiskPeople(List.of(buildRiskPeople()));
report.setSuspiciousTransactions(List.of(buildSuspiciousTransaction()));
report.setIllegalPeople(List.of(buildIllegalPerson()));
report.setAbnormalAccounts(List.of(buildAbnormalAccount()));
return report;
}
private float readPrivateFloat(Class<?> clazz, String fieldName) throws Exception {
java.lang.reflect.Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
return field.getFloat(null);
}
private CcdiProjectOverviewDashboardVO buildDashboard() {
CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
dashboard.setStats(List.of(
buildStat("总人数", 10),
buildStat("高风险", 2),
buildStat("中风险", 3),
buildStat("低风险", 1),
buildStat("无风险", 4)
));
return dashboard;
}
private CcdiProjectOverviewStatVO buildStat(String label, Integer value) {
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
stat.setLabel(label);
stat.setValue(value);
return stat;
}
private CcdiProjectOverviewReportUploadSubjectVO buildUploadSubject() {
CcdiProjectOverviewReportUploadSubjectVO row = new CcdiProjectOverviewReportUploadSubjectVO();
row.setSubjectName("测试主体");
row.setAccountNos("6222000000000001、6222000000000002");
row.setDataPeriod("3个月");
row.setFileCount(2);
return row;
}
private CcdiProjectOverviewReportParamVO buildParam() {
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName("大额交易模型");
row.setParamName("单笔大额转账金额");
row.setParamValue("5000");
row.setParamUnit("");
row.setParamDesc("单笔转账金额超过");
return row;
}
private CcdiProjectOverviewReportModelSummaryVO buildModelSummary() {
CcdiProjectOverviewReportModelSummaryVO row = new CcdiProjectOverviewReportModelSummaryVO();
row.setModelName("大额交易");
row.setWarningCount(6);
row.setPeopleCount(2);
row.setPeopleNames("张三、李四");
return row;
}
private CcdiProjectRiskModelPeopleItemVO buildRiskPeople() {
CcdiProjectRiskModelPeopleItemVO row = new CcdiProjectRiskModelPeopleItemVO();
row.setStaffName("张三");
row.setStaffCode("1001");
row.setIdNo("330000000000000001");
row.setDepartment("财务部");
row.setModelNames(List.of("大额交易"));
CcdiProjectRiskHitTagVO tag = new CcdiProjectRiskHitTagVO();
tag.setRuleName("大额转账交易");
row.setHitTagList(List.of(tag));
return row;
}
private CcdiProjectOverviewReportSuspiciousTransactionVO buildSuspiciousTransaction() {
CcdiProjectOverviewReportSuspiciousTransactionVO row = new CcdiProjectOverviewReportSuspiciousTransactionVO();
row.setTrxDate("2026-03-20 10:00:00");
row.setLeAccountNo("6222000000000001");
row.setLeAccountName("测试主体");
row.setCustomerAccountNo("6222000000000002");
row.setCustomerAccountName("对方账户");
row.setRelatedStaffName("张三");
row.setRelatedStaffCode("1001");
row.setUserMemo("转账");
row.setCashType("支出");
row.setHitTags("大额转账交易");
row.setDisplayAmount(new BigDecimal("-5000.00"));
return row;
}
private CcdiProjectEmployeeCreditNegativeExcel buildIllegalPerson() {
CcdiProjectEmployeeCreditNegativeExcel row = new CcdiProjectEmployeeCreditNegativeExcel();
row.setPersonName("李四");
row.setPersonId("330000000000000002");
row.setQueryDate("2026-03-20");
row.setCivilCnt(1);
row.setCivilLmt(new BigDecimal("20000.00"));
return row;
}
private CcdiProjectAbnormalAccountExcel buildAbnormalAccount() {
CcdiProjectAbnormalAccountExcel row = new CcdiProjectAbnormalAccountExcel();
row.setAccountNo("6222000000000003");
row.setAccountName("王五");
row.setBankName("中国银行");
row.setAbnormalType("突然销户");
row.setAbnormalTime("2026-03-20");
row.setStatus("已销户");
return row;
}
}

212
deploy/deploy-release-prod.sh Executable file
View File

@@ -0,0 +1,212 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
BACKEND_DIR="${SCRIPT_DIR}/backend"
FRONTEND_DIR="${SCRIPT_DIR}/frontend"
START_SCRIPT="${SCRIPT_DIR}/start-java-backend-prod.sh"
BACKUP_ROOT="${SCRIPT_DIR}/backups"
WORK_ROOT="${SCRIPT_DIR}/.deploy-work"
TIMESTAMP=$(date '+%Y%m%d%H%M%S')
BACKUP_DIR="${BACKUP_ROOT}/${TIMESTAMP}"
WORK_DIR="${WORK_ROOT}/release-${TIMESTAMP}"
RELEASE_ZIP="${1:-}"
RELEASE_JAR=""
RELEASE_DIST_ZIP=""
FRONTEND_SOURCE_DIR=""
timestamp() {
date '+%Y-%m-%d %H:%M:%S'
}
log_info() {
printf '[%s] %s\n' "$(timestamp)" "$1"
}
log_error() {
printf '[%s] %s\n' "$(timestamp)" "$1" >&2
}
usage() {
cat <<'EOF'
用法: ./deploy-release-prod.sh [上线压缩包路径]
目录要求:
deploy-release-prod.sh
start-java-backend-prod.sh
backend/
frontend/
ccdi_YYYYMMDD.zip
说明:
未传上线压缩包路径时,脚本会自动使用当前脚本目录下唯一的 .zip 文件。
上线压缩包根层必须包含 ruoyi-admin.jar 和 dist.zip。
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
log_error "缺少命令: $1"
exit 1
fi
}
resolve_path() {
input_path="$1"
case "${input_path}" in
/*)
printf '%s\n' "${input_path}"
;;
*)
input_dir=$(dirname "${input_path}")
input_base=$(basename "${input_path}")
printf '%s/%s\n' "$(CDPATH= cd -- "${input_dir}" && pwd)" "${input_base}"
;;
esac
}
resolve_release_zip() {
if [ -n "${RELEASE_ZIP}" ]; then
RELEASE_ZIP=$(resolve_path "${RELEASE_ZIP}")
else
mkdir -p "${WORK_ROOT}"
candidate_file="${WORK_ROOT}/zip-candidates-${TIMESTAMP}.txt"
find "${SCRIPT_DIR}" -maxdepth 1 -type f -name '*.zip' ! -name 'dist.zip' | sort > "${candidate_file}"
candidate_count=$(wc -l < "${candidate_file}" | tr -d ' ')
if [ "${candidate_count}" -eq 0 ]; then
log_error "未在脚本目录找到上线压缩包,请传入压缩包路径"
exit 1
fi
if [ "${candidate_count}" -gt 1 ]; then
log_error "脚本目录存在多个上线压缩包,请显式传入压缩包路径"
cat "${candidate_file}" >&2
exit 1
fi
RELEASE_ZIP=$(sed -n '1p' "${candidate_file}")
rm -f "${candidate_file}"
fi
if [ ! -f "${RELEASE_ZIP}" ]; then
log_error "上线压缩包不存在: ${RELEASE_ZIP}"
exit 1
fi
}
assert_layout() {
if [ ! -d "${BACKEND_DIR}" ]; then
log_error "未找到后端目录: ${BACKEND_DIR}"
exit 1
fi
if [ ! -d "${FRONTEND_DIR}" ]; then
log_error "未找到前端目录: ${FRONTEND_DIR}"
exit 1
fi
if [ ! -f "${START_SCRIPT}" ]; then
log_error "未找到后端启动脚本: ${START_SCRIPT}"
exit 1
fi
}
backup_dir() {
source_dir="$1"
target_dir="$2"
mkdir -p "${target_dir}"
if [ -n "$(find "${source_dir}" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
cp -a "${source_dir}/." "${target_dir}/"
log_info "已备份 ${source_dir}${target_dir}"
else
log_info "目录为空,已创建空备份目录: ${target_dir}"
fi
}
backup_current_files() {
mkdir -p "${BACKUP_DIR}"
backup_dir "${BACKEND_DIR}" "${BACKUP_DIR}/backend"
backup_dir "${FRONTEND_DIR}" "${BACKUP_DIR}/frontend"
}
extract_release_package() {
mkdir -p "${WORK_DIR}/release" "${WORK_DIR}/frontend"
log_info "开始解压上线压缩包: ${RELEASE_ZIP}"
unzip -q "${RELEASE_ZIP}" -d "${WORK_DIR}/release"
RELEASE_JAR="${WORK_DIR}/release/ruoyi-admin.jar"
RELEASE_DIST_ZIP="${WORK_DIR}/release/dist.zip"
if [ ! -f "${RELEASE_JAR}" ]; then
log_error "上线压缩包根层缺少 ruoyi-admin.jar"
exit 1
fi
if [ ! -f "${RELEASE_DIST_ZIP}" ]; then
log_error "上线压缩包根层缺少 dist.zip"
exit 1
fi
unzip -q "${RELEASE_DIST_ZIP}" -d "${WORK_DIR}/frontend"
FRONTEND_SOURCE_DIR="${WORK_DIR}/frontend/dist"
if [ ! -f "${FRONTEND_SOURCE_DIR}/index.html" ]; then
log_error "dist.zip 解压后未找到 dist/index.html"
exit 1
fi
}
deploy_backend() {
target_jar="${BACKEND_DIR}/ruoyi-admin.jar"
deploying_jar="${BACKEND_DIR}/.ruoyi-admin.jar.deploying"
cp "${RELEASE_JAR}" "${deploying_jar}"
mv "${deploying_jar}" "${target_jar}"
log_info "后端 Jar 已部署: ${target_jar}"
}
deploy_frontend() {
find "${FRONTEND_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
cp -a "${FRONTEND_SOURCE_DIR}/." "${FRONTEND_DIR}/"
log_info "前端文件已部署到: ${FRONTEND_DIR}"
}
cleanup_work_dir() {
rm -rf "${WORK_DIR}"
}
main() {
case "${1:-}" in
-h|--help|help)
usage
exit 0
;;
esac
if [ "$#" -gt 1 ]; then
usage
exit 1
fi
require_command "unzip"
require_command "find"
resolve_release_zip
assert_layout
backup_current_files
trap cleanup_work_dir 0
extract_release_package
deploy_backend
deploy_frontend
cleanup_work_dir
trap - 0
log_info "部署完成,备份目录: ${BACKUP_DIR}"
log_info "开始重启后端并输出日志"
bash "${START_SCRIPT}" restart
}
main "$@"

310
deploy/start-java-backend-prod.sh Executable file
View File

@@ -0,0 +1,310 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ==================== 生产配置区:按服务器实际路径修改 ====================
# JDK 安装目录。留空时使用服务器已有 JAVA_HOME仍为空时使用 PATH 中的 java。
BACKEND_JAVA_HOME=""
# 后端 Jar 所在目录。生产目录结构为启动脚本在外层Jar 位于 backend/ruoyi-admin.jar。
APP_HOME="${SCRIPT_DIR}/backend"
# 后端 Jar 文件名。
JAR_NAME="ruoyi-admin.jar"
# Spring Profile。
SPRING_PROFILES_ACTIVE="uat"
# JVM 参数。
JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError"
# 额外应用启动参数,例如:--server.port=8080
APP_ARGS=""
# 停止进程等待秒数。
STOP_WAIT_SECONDS=30
# ==================== 以下为脚本逻辑,一般不需要修改 ====================
if [[ "${APP_HOME}" != /* ]]; then
APP_HOME="${SCRIPT_DIR}/${APP_HOME}"
fi
JAR_PATH="${APP_HOME}/${JAR_NAME}"
RELATIVE_JAR_PATH=""
if [[ "${APP_HOME}" == "${SCRIPT_DIR}/"* ]]; then
RELATIVE_JAR_PATH="${APP_HOME#${SCRIPT_DIR}/}/${JAR_NAME}"
fi
LOG_DIR="${APP_HOME}/logs"
CONSOLE_LOG="${LOG_DIR}/backend-console.log"
PID_FILE="${LOG_DIR}/backend-java.pid"
APP_MARKER="-Dccdi.backend.prod.home=${APP_HOME}"
JAVA_CMD="java"
timestamp() {
date "+%Y-%m-%d %H:%M:%S"
}
log_info() {
printf '[%s] %s\n' "$(timestamp)" "$1"
}
log_error() {
printf '[%s] %s\n' "$(timestamp)" "$1" >&2
}
usage() {
cat <<'EOF'
用法: ./start-java-backend-prod.sh [start|stop|restart|status|logs]
默认动作:
start 先关闭旧后端进程,再启动生产后端 Jar启动成功后持续输出控制台日志
常用配置:
配置统一写在脚本顶部“生产配置区”,包括 BACKEND_JAVA_HOME、APP_HOME、SPRING_PROFILES_ACTIVE、JAVA_OPTS。
示例:
./start-java-backend-prod.sh restart
EOF
}
ensure_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
log_error "缺少命令: ${command_name}"
exit 1
fi
}
resolve_java_cmd() {
local configured_java_home="${BACKEND_JAVA_HOME}"
if [[ -z "${configured_java_home}" ]]; then
configured_java_home="${JAVA_HOME:-}"
fi
if [[ -n "${configured_java_home}" ]]; then
configured_java_home="${configured_java_home%/}"
if [[ ! -x "${configured_java_home}/bin/java" ]]; then
log_error "配置的 JAVA_HOME 无效,未找到可执行文件: ${configured_java_home}/bin/java"
exit 1
fi
export JAVA_HOME="${configured_java_home}"
JAVA_CMD="${JAVA_HOME}/bin/java"
else
ensure_command "java"
JAVA_CMD="java"
fi
log_info "使用 Java 命令: ${JAVA_CMD}"
}
get_process_table() {
local process_table
if ! process_table="$(ps -ef 2>/dev/null)"; then
log_error "执行 ps -ef 失败,无法扫描旧进程"
return 1
fi
printf '%s\n' "${process_table}"
}
is_managed_pid() {
local pid="$1"
if [[ -z "${pid}" ]] || ! kill -0 "${pid}" 2>/dev/null; then
return 1
fi
local process_table
if ! process_table="$(get_process_table)"; then
return 1
fi
local line
while IFS= read -r line; do
set -- ${line}
if [[ "${2:-}" == "${pid}" ]] && is_backend_process_line "${line}"; then
return 0
fi
done <<<"${process_table}"
return 1
}
is_backend_process_line() {
local line="$1"
[[ "${line}" != *"<defunct>"* ]] || return 1
[[ "${line}" == *" -jar ${JAR_PATH}"* ]] && return 0
[[ -n "${RELATIVE_JAR_PATH}" && "${line}" == *" -jar ${RELATIVE_JAR_PATH}"* ]]
}
collect_pids() {
local all_pids=""
local pid
local process_table
if ! process_table="$(get_process_table)"; then
return 1
fi
if [[ -f "${PID_FILE}" ]]; then
pid="$(cat "${PID_FILE}" 2>/dev/null || true)"
if is_managed_pid "${pid}"; then
all_pids="${all_pids} ${pid}"
fi
fi
local line
while IFS= read -r line; do
set -- ${line}
pid="${2:-}"
if [[ "${pid}" =~ ^[0-9]+$ ]] && is_backend_process_line "${line}"; then
all_pids="${all_pids} ${pid}"
fi
done <<<"${process_table}"
local unique_pids=""
for pid in ${all_pids}; do
case " ${unique_pids} " in
*" ${pid} "*) ;;
*) unique_pids="${unique_pids} ${pid}" ;;
esac
done
xargs <<<"${unique_pids}" 2>/dev/null || true
}
start_backend() {
resolve_java_cmd
if [[ ! -f "${JAR_PATH}" ]]; then
log_error "未找到后端 Jar: ${JAR_PATH}"
exit 1
fi
local running_pids
running_pids="$(collect_pids)"
if [[ -n "${running_pids}" ]]; then
log_error "检测到后端已在运行: ${running_pids}"
exit 1
fi
mkdir -p "${LOG_DIR}"
printf '\n===== %s start =====\n' "$(timestamp)" >>"${CONSOLE_LOG}"
local profile_arg=""
if [[ -n "${SPRING_PROFILES_ACTIVE}" ]]; then
profile_arg="--spring.profiles.active=${SPRING_PROFILES_ACTIVE}"
fi
log_info "开始启动后端 Jar: ${JAR_PATH}"
nohup "${JAVA_CMD}" "${APP_MARKER}" ${JAVA_OPTS} -jar "${JAR_PATH}" ${profile_arg} ${APP_ARGS} >>"${CONSOLE_LOG}" 2>&1 &
echo $! >"${PID_FILE}"
sleep 3
local starter_pid
starter_pid="$(cat "${PID_FILE}" 2>/dev/null || true)"
if [[ -z "${starter_pid}" ]] || ! kill -0 "${starter_pid}" 2>/dev/null; then
log_error "启动命令未保持运行,请检查日志: ${CONSOLE_LOG}"
exit 1
fi
log_info "后端启动完成PID: ${starter_pid}"
}
stop_backend() {
local pids
pids="$(collect_pids)"
if [[ -z "${pids}" ]]; then
log_info "未发现运行中的后端进程"
rm -f "${PID_FILE}"
return 0
fi
log_info "准备停止后端进程: ${pids}"
local pid
for pid in ${pids}; do
kill -TERM "${pid}" 2>/dev/null || true
done
local elapsed=0
local remaining_pids="${pids}"
while [[ -n "${remaining_pids}" && "${elapsed}" -lt "${STOP_WAIT_SECONDS}" ]]; do
sleep 1
elapsed=$((elapsed + 1))
remaining_pids=""
for pid in ${pids}; do
if kill -0 "${pid}" 2>/dev/null; then
remaining_pids="${remaining_pids} ${pid}"
fi
done
remaining_pids="$(xargs <<<"${remaining_pids}" 2>/dev/null || true)"
done
if [[ -n "${remaining_pids}" ]]; then
log_info "仍有进程未退出,执行强制停止: ${remaining_pids}"
for pid in ${remaining_pids}; do
kill -KILL "${pid}" 2>/dev/null || true
done
fi
rm -f "${PID_FILE}"
log_info "后端停止完成"
}
status_backend() {
local pids
pids="$(collect_pids)"
if [[ -n "${pids}" ]]; then
log_info "后端正在运行,进程: ${pids}"
return 0
fi
log_info "后端未运行"
}
follow_logs() {
mkdir -p "${LOG_DIR}"
touch "${CONSOLE_LOG}"
log_info "持续输出日志中,按 Ctrl+C 仅退出日志查看,不会停止后端进程"
tail -n 200 -F "${CONSOLE_LOG}"
}
start_action() {
stop_backend
start_backend
follow_logs
}
main() {
local action="${1:-start}"
case "${action}" in
start)
start_action
;;
stop)
stop_backend
;;
restart)
start_action
;;
status)
status_backend
;;
logs)
follow_logs
;;
-h|--help|help)
usage
;;
*)
usage
exit 1
;;
esac
}
main "$@"

View File

@@ -0,0 +1,493 @@
# 关联业务自动补入实体库 Backend Implementation Plan
> **执行约束:** 按当前项目 `AGENTS.md` 执行;未获得用户明确要求时不启用 subagent。Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 新建和导入员工亲属实体关联、中介实体关联、信贷客户实体关联、招投标供应商时,实体库缺失的企业自动写入 `ccdi_enterprise_base_info`
**Architecture:** 新增一个后端内部实体库自动补全服务,统一处理“已存在不覆盖、缺失则最小插入、同批去重、来源和风险等级映射”。各业务 Service 在业务校验通过、业务数据落库前调用该能力;`EnterpriseSource` 枚举新增 `SUPPLIER` 并继续由现有 `/ccdi/enum/enterpriseSource` 接口驱动前端。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito, Maven.
---
## File Structure
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseSource.java`
- 新增 `SUPPLIER("SUPPLIER", "供应商")`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java`
- 内部补全服务,封装单条和批量实体补入。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationServiceImpl.java`
- 新建员工亲属实体关联前补实体库。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffEnterpriseRelationImportServiceImpl.java`
- 导入成功行批量补实体库。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCustEnterpriseRelationServiceImpl.java`
- 新建信贷客户实体关联前补实体库。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCustEnterpriseRelationImportServiceImpl.java`
- 导入成功行批量补实体库。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java`
- 中介实体关联新建时取消实体库必须已存在校验,改为补实体库。
- 中介库管理新增实体时风险等级默认高风险。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java`
- 取消“机构表不存在”失败条件,改为成功行批量补实体库。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java`
- 中介库管理导入实体风险等级默认高风险。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionServiceImpl.java`
- 招投标新建时供应商补实体库。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiPurchaseTransactionImportServiceImpl.java`
- 招投标导入成功采购事项的供应商批量补实体库。
- Test: existing unit tests under `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/`
- 扩展或新增对应 Service/Import/Controller 测试。
- Create: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md`
- 记录修改内容、影响范围、验证情况。
## Task 1: EnterpriseSource 枚举与接口契约
**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/enums/EnterpriseSource.java`
- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiEnumControllerTest.java`
- [ ] **Step 1: 写失败测试**
`CcdiEnumControllerTest#getEnterpriseSourceOptions_shouldReturnConfiguredOptions` 中断言返回值包含 `SUPPLIER/供应商`
```java
assertTrue(data.stream()
.map(EnumOptionVO.class::cast)
.anyMatch(option ->
"SUPPLIER".equals(option.getValue()) && "供应商".equals(option.getLabel())));
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiEnumControllerTest test
```
Expected: FAIL提示未找到 `SUPPLIER`
- [ ] **Step 3: 实现枚举**
`EnterpriseSource` 中新增:
```java
SUPPLIER("SUPPLIER", "供应商"),
```
保持 `contains``resolveCode``getDescByCode` 通过 `values()` 自动生效。
- [ ] **Step 4: 运行测试确认通过**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiEnumControllerTest test
```
Expected: PASS。
## Task 2: 实体库自动补全服务
**Files:**
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillServiceTest.java`
- [ ] **Step 1: 写服务测试**
覆盖以下行为:
- 已存在实体不插入、不覆盖。
- 缺失实体插入最小记录。
- 中介来源写 `riskLevel=1`
- 员工亲属、信贷客户、供应商来源写 `riskLevel=null`
- 批量同一信用代码只插一次,并使用首次有效名称。
- 插入时遇到主键重复按已存在处理。
核心断言示例:
```java
assertEquals("SUPPLIER", captured.getEntSource());
assertNull(captured.getRiskLevel());
assertEquals("IMPORT", captured.getDataSource());
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=EnterpriseAutoFillServiceTest test
```
Expected: FAIL类不存在。
- [ ] **Step 3: 实现服务接口**
创建内部记录类型和方法:
```java
@Service
public class EnterpriseAutoFillService {
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
public record EnterpriseFillItem(
String socialCreditCode,
String enterpriseName,
String entSource,
String dataSource,
String userName
) {}
@Transactional
public void ensureExists(EnterpriseFillItem item) {
ensureExistsBatch(List.of(item));
}
@Transactional
public void ensureExistsBatch(List<EnterpriseFillItem> items) {
// trim、过滤空信用代码、按 socialCreditCode 首次出现去重
// selectBatchIds 查询已存在记录
// 组装 CcdiEnterpriseBaseInfo 最小实体
// riskLevel: INTERMEDIARY -> "1",其他 -> null
// dataSource: MANUAL 或 IMPORT
// 分批调用 enterpriseBaseInfoMapper.insertBatch
// 捕获 DuplicateKeyException 后继续逐条 selectById/insert重复则忽略
}
}
```
实现注意:
- 不调用 `CcdiEnterpriseBaseInfoServiceImpl#insertEnterpriseBaseInfo`,避免复用手工新增风险等级校验。
- 对非中介来源显式 `setRiskLevel(null)`
- 不更新已存在实体。
- `enterpriseName` 使用来源业务已通过校验的名称,不增加额外兜底。
- [ ] **Step 4: 运行服务测试**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=EnterpriseAutoFillServiceTest test
```
Expected: PASS。
## Task 3: 员工亲属实体关联接入
**Files:**
- Modify: `CcdiStaffEnterpriseRelationServiceImpl.java`
- Modify: `CcdiStaffEnterpriseRelationImportServiceImpl.java`
- Modify: `CcdiStaffEnterpriseRelationServiceImplTest.java`
- Modify: `CcdiStaffEnterpriseRelationImportServiceImplTest.java`
- [ ] **Step 1: 写新建测试**
`insertRelation_shouldAllowValidFamily` 中注入 `EnterpriseAutoFillService` mock并验证
```java
verify(enterpriseAutoFillService).ensureExists(argThat(item ->
"91310000123456789A".equals(item.socialCreditCode())
&& "测试企业".equals(item.enterpriseName())
&& "EMP_RELATION".equals(item.entSource())
&& "MANUAL".equals(item.dataSource())));
```
- [ ] **Step 2: 写导入测试**
扩展导入测试,验证成功行调用批量补入,失败行不进入补入集合。
- [ ] **Step 3: 运行员工亲属测试确认失败**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest test
```
Expected: FAIL尚未调用自动补全服务。
- [ ] **Step 4: 实现新建接入**
`insertRelation` 中,`existsByPersonIdAndSocialCreditCode` 通过后、`relationMapper.insert` 前调用:
```java
enterpriseAutoFillService.ensureExists(new EnterpriseAutoFillService.EnterpriseFillItem(
addDTO.getSocialCreditCode(),
addDTO.getEnterpriseName(),
EnterpriseSource.EMP_RELATION.getCode(),
DataSource.MANUAL.getCode(),
SecurityUtils.getUsername()
));
```
- [ ] **Step 5: 实现导入接入**
`importRelationAsync` 成功构建 `newRecords` 后、`saveBatch(newRecords, 500)` 前,按成功记录组装补入集合:
```java
enterpriseAutoFillService.ensureExistsBatch(newRecords.stream()
.map(item -> new EnterpriseFillItem(item.getSocialCreditCode(), item.getEnterpriseName(),
EnterpriseSource.EMP_RELATION.getCode(), DataSource.IMPORT.getCode(), userName))
.toList());
```
- [ ] **Step 6: 运行员工亲属测试**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest test
```
Expected: PASS。
## Task 4: 信贷客户实体关联接入
**Files:**
- Modify: `CcdiCustEnterpriseRelationServiceImpl.java`
- Modify: `CcdiCustEnterpriseRelationImportServiceImpl.java`
- Test: add `CcdiCustEnterpriseRelationServiceImplTest.java` if missing
- Test: add or extend `CcdiCustEnterpriseRelationImportServiceImplTest.java`
- [ ] **Step 1: 写新建测试**
验证 `insertRelation` 成功时调用自动补全:
```java
assertEquals("CREDIT_CUSTOMER", item.entSource());
assertEquals("MANUAL", item.dataSource());
```
- [ ] **Step 2: 写导入测试**
准备一条成功、一条重复组合失败,验证只有成功行传入 `ensureExistsBatch`
- [ ] **Step 3: 运行测试确认失败**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiCustEnterpriseRelationServiceImplTest,CcdiCustEnterpriseRelationImportServiceImplTest test
```
Expected: FAIL尚未调用自动补全服务。
- [ ] **Step 4: 实现新建接入**
`insertRelation` 唯一性校验后、插入前调用自动补全,来源 `CREDIT_CUSTOMER`,数据来源 `MANUAL`
- [ ] **Step 5: 实现导入接入**
`importRelationAsync` 成功记录批量插入前调用自动补全,来源 `CREDIT_CUSTOMER`,数据来源 `IMPORT`
- [ ] **Step 6: 运行测试**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiCustEnterpriseRelationServiceImplTest,CcdiCustEnterpriseRelationImportServiceImplTest test
```
Expected: PASS。
## Task 5: 中介实体关联和中介实体管理规则
**Files:**
- Modify: `CcdiIntermediaryServiceImpl.java`
- Modify: `CcdiIntermediaryEnterpriseRelationImportServiceImpl.java`
- Modify: `CcdiEnterpriseBaseInfoImportServiceImpl.java`
- Modify: `CcdiIntermediaryServiceImplTest.java`
- Modify: `CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java`
- Modify: `CcdiEnterpriseBaseInfoImportServiceImplTest.java`
- [ ] **Step 1: 写中介实体关联新建测试**
验证实体库缺失不再抛“关联机构不存在”,而是调用自动补全并插入关联:
```java
when(enterpriseRelationMapper.existsByIntermediaryBizIdAndSocialCreditCode("owner-biz", uscc)).thenReturn(false);
verify(enterpriseAutoFillService).ensureExists(argThat(item ->
"INTERMEDIARY".equals(item.entSource()) && "MANUAL".equals(item.dataSource())));
verify(enterpriseRelationMapper).insert(any(CcdiIntermediaryEnterpriseRelation.class));
```
- [ ] **Step 2: 写中介实体关联导入测试**
将现有 `importEnterpriseRelationAsync_shouldFailWhenEnterpriseDoesNotExist` 改成成功场景,断言:
- 不再产生失败记录。
- 调用 `ensureExistsBatch`
- 插入关联记录。
- [ ] **Step 3: 写中介库管理默认高风险测试**
`CcdiEnterpriseBaseInfoImportServiceImplTest` 增加:
```java
excel.setRiskLevel(null);
excel.setEntSource("中介");
CcdiEnterpriseBaseInfo entity = service.validateAndBuildEntity(excel, Set.of(), new HashSet<>(), "admin");
assertEquals("1", entity.getRiskLevel());
assertEquals("INTERMEDIARY", entity.getEntSource());
```
`CcdiIntermediaryServiceImplTest` 验证 `insertIntermediaryEntity` 未传风险等级时写入 `1`
- [ ] **Step 4: 运行测试确认失败**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest test
```
Expected: FAIL。
- [ ] **Step 5: 实现新建接入**
修改 `validateEnterpriseRelation`:保留中介本人和重复组合校验,删除 `enterpriseBaseInfoMapper.selectById(socialCreditCode) == null` 抛错。
`insertIntermediaryEnterpriseRelation` 插入前调用自动补全,来源 `INTERMEDIARY`,数据来源 `MANUAL`
- [ ] **Step 6: 实现导入接入**
在导入服务中删除 `getExistingEnterpriseCodes` 的失败判断。成功记录插入前按 Excel 行组装实体补入,来源 `INTERMEDIARY`,数据来源 `IMPORT`
- [ ] **Step 7: 实现中介实体默认高风险**
`insertIntermediaryEntity` 中,如果 `riskLevel` 为空,设置为 `"1"`
`CcdiEnterpriseBaseInfoImportServiceImpl#validateAndBuildEntity` 中,当解析出的 `entSource``INTERMEDIARY``riskLevel` 为空时,设置 `"1"`
- [ ] **Step 8: 运行测试**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest test
```
Expected: PASS。
## Task 6: 招投标供应商接入
**Files:**
- Modify: `CcdiPurchaseTransactionServiceImpl.java`
- Modify: `CcdiPurchaseTransactionImportServiceImpl.java`
- Test: add `CcdiPurchaseTransactionServiceImplTest.java` if missing
- Modify: `CcdiPurchaseTransactionFeatureContractTest.java` or add import service unit test
- [ ] **Step 1: 写新建测试**
验证 `insertTransaction` 成功时,仅对 `supplierUscc` 不为空的供应商调用自动补全:
```java
assertEquals("SUPPLIER", item.entSource());
assertEquals("MANUAL", item.dataSource());
assertEquals("供应商A", item.enterpriseName());
```
- [ ] **Step 2: 写导入测试**
准备一个成功采购事项和一个失败采购事项,断言只有成功事项的供应商进入 `ensureExistsBatch`,且来源为 `SUPPLIER`、数据来源为 `IMPORT`
- [ ] **Step 3: 运行测试确认失败**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiPurchaseTransactionServiceImplTest,CcdiPurchaseTransactionImportServiceImplTest,CcdiPurchaseTransactionFeatureContractTest test
```
Expected: FAIL。
- [ ] **Step 4: 实现新建接入**
`insertTransaction` 中,`buildSupplierEntities` 和校验完成后、写主从表前,收集供应商:
```java
enterpriseAutoFillService.ensureExistsBatch(supplierList.stream()
.filter(item -> StringUtils.isNotEmpty(item.getSupplierUscc()))
.map(item -> new EnterpriseFillItem(item.getSupplierUscc(), item.getSupplierName(),
EnterpriseSource.SUPPLIER.getCode(), DataSource.MANUAL.getCode(), SecurityUtils.getUsername()))
.toList());
```
- [ ] **Step 5: 实现导入接入**
`importTransactionAsync` 中,按成功构建的 `newSuppliers` 收集供应商实体,在 `saveBatch(newTransactions, 500)` 之前调用自动补全。失败事项的供应商不进入 `newSuppliers`,天然不补。
- [ ] **Step 6: 运行测试**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiPurchaseTransactionServiceImplTest,CcdiPurchaseTransactionImportServiceImplTest,CcdiPurchaseTransactionFeatureContractTest test
```
Expected: PASS。
## Task 7: 集成验证与实施记录
**Files:**
- Create: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md`
- [ ] **Step 1: 运行后端相关测试集合**
Run:
```bash
mvn -pl ccdi-info-collection -Dtest=CcdiEnumControllerTest,EnterpriseAutoFillServiceTest,CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest,CcdiCustEnterpriseRelationServiceImplTest,CcdiCustEnterpriseRelationImportServiceImplTest,CcdiIntermediaryServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest,CcdiPurchaseTransactionServiceImplTest,CcdiPurchaseTransactionImportServiceImplTest,CcdiPurchaseTransactionFeatureContractTest test
```
Expected: BUILD SUCCESS。
- [ ] **Step 2: 如涉及数据库实测,确认 `risk_level` 落库值**
验证样本:
- 员工亲属自动补入:`risk_level IS NULL`
- 信贷客户自动补入:`risk_level IS NULL`
- 招投标供应商自动补入:`risk_level IS NULL`
- 中介自动补入:`risk_level = '1'`
- [ ] **Step 3: 写实施记录**
实施记录至少包含:
```markdown
# 关联业务自动补入实体库实施记录
## 修改内容
- 新增实体库自动补全服务
- 接入员工亲属、中介、信贷客户、招投标链路
- 新增 SUPPLIER 企业来源
## 影响范围
- ccdi-info-collection 后端服务
- 实体库管理企业来源枚举接口
## 验证情况
- 列出 Maven 测试命令与结果
- 列出页面或数据库验证结果
```
- [ ] **Step 4: 检查工作区**
Run:
```bash
git status --short
```
Expected: 仅包含本次功能相关源码、测试和实施记录,不包含 `.DS_Store` 或生成测试文件。
- [ ] **Step 5: 提交后端改动**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection ccdi-info-collection/src/test/java/com/ruoyi/info/collection docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md
git commit -m "实现关联业务自动补入实体库"
```

View File

@@ -0,0 +1,26 @@
# Fund Graph Backend Implementation Plan
**目标:** 基于图谱结果表和手工补录边提供一期资金流图谱后端能力,支持按身份证号或员工姓名查询一层资金往来,并支持点击图谱边后分页查看该边每一笔流水。
**一期范围:**
- 搜索条件:`projectId` 保留为历史字段但不参与过滤,`keyword` 支持身份证号精确匹配、员工姓名匹配。
- 图谱范围:仅返回当前人员或姓名命中的直接对手方资金往来,不自动追溯二、三层。
- 边明细:按图谱边的 `fromKey/toKey` 查询每笔流水,保留交易时间、本方、对手方、摘要、金额、方向。
- 预留扩展DTO/VO 保留 `depth``canTrace``canExpand` 字段,后续可以扩展节点点击追溯。
**实现内容:**
- 新增 `CcdiFundGraphController`,暴露 `/ccdi/project/fund-graph/search``/ccdi/project/fund-graph/graph``/ccdi/project/fund-graph/edge-detail``/ccdi/project/fund-graph/manual-edge`
- 新增 `ICcdiFundGraphService``CcdiFundGraphServiceImpl`负责查询参数归一化、TopN 限制、节点构建和追溯字段赋值。
- 新增 `CcdiFundGraphMapper` 与 XML SQL基于 `lx_fund_flow_*` 图谱结果表查询主体、汇总边、逐笔流水,并支持手工边落库。
- 新增 DTO/VO`CcdiFundGraphQueryDTO``CcdiFundGraphEdgeDetailQueryDTO``CcdiFundGraphVO``CcdiFundGraphNodeVO``CcdiFundGraphEdgeVO``CcdiFundGraphStatementVO`
**数据口径:**
- 一期查询不按 `projectId` 过滤,统一按全局图谱关系查询。
- 资金边来自 `lx_fund_flow_detail_edge` 归并后的主体层汇总边,并叠加 `lx_fund_flow_manual_edge` 手工边。
- 图谱边按 `fromKey/toKey/direction/familyRelationType` 聚合,统计累计金额、交易笔数、首末交易时间。
- 手工边与真实边统一排序后再按 `limit` 截断,避免结果条数和排序口径不一致。
**后续追溯口子:**
- 当前接口接收 `depth` 但服务层固定为一期一层。
- 节点返回 `canExpand`,边返回 `canTrace`,后续可新增 `rootKey/currentNodeKey/depth` 参数做按节点展开。
- 一期 SQL 已把人员、企业、账号、名称代理做成统一节点键,后续多层追溯可沿用同一套节点键。

View File

@@ -0,0 +1,244 @@
# 关联业务自动补入实体库 Frontend Implementation Plan
> **执行约束:** 按当前项目 `AGENTS.md` 执行;未获得用户明确要求时不启用 subagent。Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 验证前端通过现有企业来源枚举接口展示新增 `SUPPLIER=供应商`,并在真实页面确认自动补入实体库后的展示链路可用。
**Architecture:** 本次不新增前端交互,不修改前端源码。企业来源选项由后端 `/ccdi/enum/enterpriseSource` 返回,实体库管理页与招投标详情页沿用 `getEnterpriseSourceOptions()` 展示新增来源;前端工作重点是运行真实页面验证并记录结果。
**Tech Stack:** Vue 2, Element UI, npm, nvm, Playwright.
---
## File Structure
- No source changes expected: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue`
- 已通过 `getEnterpriseSourceOptions()` 获取企业来源。
- No source changes expected: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- 企业详情弹窗已通过 `getEnterpriseSourceOptions()` 格式化企业来源。
- Create or update: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md`
- 补充前端真实页面验证结果。
- Generated test files:
- 如需生成导入样本,放在 `output/playwright/``output/spreadsheet/`,不提交到 git。
## Task 1: 前端源码确认
**Files:**
- Read: `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue`
- Read: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- Read: `ruoyi-ui/src/api/ccdiEnum.js`
- [ ] **Step 1: 确认企业来源接口使用点**
Run:
```bash
rg -n "getEnterpriseSourceOptions|formatEnterpriseSource|enterpriseSourceOptions" ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue ruoyi-ui/src/api/ccdiEnum.js
```
Expected:
- 实体库管理页调用 `getEnterpriseSourceOptions()`
- 招投标详情企业弹窗调用 `getEnterpriseSourceOptions()`
- API 路径为 `/ccdi/enum/enterpriseSource`
- [ ] **Step 2: 确认不存在本地硬编码映射**
Expected: 未发现页面本地写死企业来源映射;如发现硬编码映射,停止实施并先修订本计划。
## Task 2: 前端启动准备
**Files:**
- Read: `ruoyi-ui/package.json`
- Use: `ruoyi-ui/.nvmrc` if present
- [ ] **Step 1: 使用 nvm 确认 Node 版本**
Run:
```bash
cd ruoyi-ui
source ~/.nvm/nvm.sh
nvm use
node -v
```
Expected: 切换到项目要求的 Node 版本。
- [ ] **Step 2: 启动前端开发服务**
Run:
```bash
cd ruoyi-ui
source ~/.nvm/nvm.sh
nvm use
npm run dev
```
Expected: 前端服务启动成功,记录实际 URL。若端口占用按 Vite/Vue CLI 输出使用实际端口。
- [ ] **Step 3: 启动后端**
Run:
```bash
sh bin/restart_java_backend.sh
```
Expected: 后端 `62318` 可访问。
测试结束后必须关闭本次启动的前后端进程。
## Task 3: 实体库管理页面验证
**Files:**
- Verify: real page `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue`
- Do not use: prototype pages
- [ ] **Step 1: Playwright 打开真实实体库管理页面**
进入实际路由:
```text
http://localhost:8080/maintain/enterpriseBaseInfo
```
Expected: 页面正常加载。
- [ ] **Step 2: 验证企业来源下拉包含供应商**
操作:
- 打开查询区“企业来源”下拉。
- 检查存在“供应商”选项。
Expected: 下拉出现“供应商”。
- [ ] **Step 3: 验证列表/详情展示**
准备后端自动补入的一条 `ent_source=SUPPLIER` 测试企业后:
- 在实体库管理页面搜索该统一社会信用代码。
- 检查列表企业来源显示“供应商”。
- 打开详情,检查企业来源显示“供应商”,风险等级为空时显示为空值占位。
Expected: 枚举中文展示正确。
## Task 4: 招投标真实页面验证
**Files:**
- Verify: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- [ ] **Step 1: 打开真实招投标信息维护页面**
进入实际路由,例如:
```text
http://localhost:8080/maintain/purchaseTransaction
```
Expected: 页面正常加载。
- [ ] **Step 2: 新建含供应商统一信用代码的招投标记录**
使用真实页面新增测试数据:
- 采购事项 ID 使用本轮唯一测试值。
- 供应商明细中至少一条填写供应商名称和统一信用代码。
Expected: 保存成功。
- [ ] **Step 3: 回到实体库管理验证供应商自动补入**
用供应商统一信用代码查询实体库。
Expected:
- 能查到实体库记录。
- 企业名称为供应商名称。
- 企业来源显示“供应商”。
- 风险等级为空。
- [ ] **Step 4: 清理测试数据**
删除本轮新建的招投标测试数据和自动补入的实体库测试数据。若实体库记录已有关联限制,先删除业务数据再删除实体库记录。
Expected: 页面列表回到测试前状态。
## Task 5: 导入页面验证
**Files:**
- Generated samples: `output/playwright/` or `output/spreadsheet/`
- [ ] **Step 1: 在真实页面下载导入模板**
必须从当前业务页面点击下载模板,不手工凭记忆构造表头。
Expected: 获取当前模板。
- [ ] **Step 2: 基于模板生成测试文件**
至少覆盖:
- 员工亲属实体关联:页面 `/maintain/staffEnterpriseRelation`,点击“导入”后在弹窗中点击“下载模板”,接口 `ccdi/staffEnterpriseRelation/importTemplate`,上传接口 `/ccdi/staffEnterpriseRelation/importData`,验证成功行实体自动补入 `EMP_RELATION`
- 信贷客户实体关联:页面 `/maintain/custEnterpriseRelation`,点击“导入”后在弹窗中点击“下载模板”,接口 `ccdi/custEnterpriseRelation/importTemplate`,上传接口 `/ccdi/custEnterpriseRelation/importData`,验证成功行实体自动补入 `CREDIT_CUSTOMER`
- 中介实体关联:页面 `/maintain/intermediary`,点击“导入中介实体关联关系”,在导入弹窗下载模板,接口 `ccdi/intermediary/importEnterpriseRelationTemplate`,上传接口 `/ccdi/intermediary/importEnterpriseRelationData`,验证成功行实体自动补入 `INTERMEDIARY` 且风险等级高风险。
- 招投标信息维护:页面 `/maintain/purchaseTransaction`,点击“导入”后在弹窗中点击“下载模板”,接口 `ccdi/purchaseTransaction/importTemplate`,上传接口 `/ccdi/purchaseTransaction/importData`,验证供应商统一信用代码自动补入 `SUPPLIER`
- 每个页面至少包含一个混合成功失败样本,验证失败行不补实体。
Expected: 测试文件保存在 `output/playwright/``output/spreadsheet/`,不提交 git。
- [ ] **Step 3: 上传并核对导入状态**
在真实页面上传文件,核对:
- 页面提示
- 导入状态
- 失败记录弹窗
- 列表总数变化
- 实体库是否新增对应实体
Expected: 成功行补实体,失败行不补实体。
- [ ] **Step 4: 清理测试数据和任务缓存**
删除本轮成功写入的业务数据和实体库数据,清理页面本地导入任务缓存。
Expected: 页面和数据库不残留本轮测试数据。
## Task 6: 记录验证结果
**Files:**
- Modify: `docs/reports/implementation/2026-04-26-enterprise-auto-fill-implementation.md`
- [ ] **Step 1: 补充前端验证记录**
记录:
```markdown
## 前端验证
- Node 版本:
- 前端 URL
- 后端 URL
- 实体库企业来源“供应商”展示:
- 招投标供应商自动补入页面验证:
- 导入页面验证:
- 测试数据清理:
```
- [ ] **Step 2: 停止测试进程**
停止本次启动的前端和后端进程。
Expected: 无测试进程残留。
- [ ] **Step 3: 检查生成文件未进入 git**
Run:
```bash
git status --short
```
Expected: `output/playwright/``output/spreadsheet/` 下生成测试文件不在待提交范围。

View File

@@ -0,0 +1,34 @@
# 员工信息维护与招聘信息管理正式化外壳前端实施计划
## 目标
- 基于当前本地最新代码,为 `ccdiBaseStaff``ccdiStaffRecruitment` 试套正式化外壳样式。
- 仅调整查询区、工具条、表格区、分页区与弹窗壳层视觉。
- 不改字段顺序、不改按钮位置、不改功能块结构。
## 范围
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- `ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
- `ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
## 方案
- 复用现有 `app-container``query-form``mb8`、弹窗 class只补最少样式。
- 给列表区新增最小表格外壳,保证分页和表格归一。
- 通过边框、浅底、留白和表头背景统一正式化视觉。
## 验证
- `node tests/unit/base-staff-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
- `node tests/unit/employee-asset-maintenance-layout.test.js`
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
## 完成标准
- 两个页面外壳样式统一
- 按钮顺序和功能入口保持不变
- 单测通过
- 浏览器实测通过

View File

@@ -0,0 +1,65 @@
# 2026-04-29 批量正式化外壳样式实施计划
## 目标
- 基于当前本地最新前端代码,批量推进信息维护相关页面的正式化外壳样式。
- 严格保持“只改样式、不改内容和功能”的边界。
- 复用已经在详情弹窗、员工信息维护页、招聘信息管理页验证过的正式化样式骨架。
## 范围
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
## 实施策略
### 1. 查询区统一正式化
- 保留原有查询字段、排布逻辑和按钮位置。
- 为查询区补统一白色面板、边框、克制圆角和更稳重的标签文字。
- 收紧表单项底部留白,统一输入框、下拉框、日期控件的边框和圆角。
### 2. 工具条统一正式化
- 保留搜索、重置、新增、导入、失败记录入口及其相对位置。
- 统一工具条外层白色承载区。
- 按钮仅调整圆角、边框与视觉重量,不改变语义和行为。
### 3. 表格承载区统一正式化
- 新增或复用 `formal-table-shell` 包裹列表表格与分页区。
- 收紧表头和行高,提升单屏信息密度。
- 主体文本尽量左对齐,保留选择列和操作列居中。
### 4. 弹窗与详情区统一正式化
- 统一弹窗圆角、头部下边线、正文浅底。
- 详情区、导入弹窗、编辑弹窗使用更克制的信息面板样式。
- 不重排现有字段,不新增删减交互块。
## 验证计划
- 复用现有样式契约单测,确保已完成页面没有回退。
- 使用浏览器打开真实业务路由进行验证,禁止使用 prototype 页面替代。
- 核对关键页面是否保持:
- 查询区与工具条仍在原位置
- 新增、导入、失败记录按钮仍按原顺序出现
- 表格列和弹窗内容结构不变
## 风险控制
- 不使用旧 patch 中的结构改法,只借用可复用的正式化视觉参数。
- 每个页面只处理最外层承载和控件外观,不触碰业务字段、接口、校验、按钮逻辑。
- 若真实页面路由可访问,则以真实页面结果为准;若不可访问,保留源码级验证说明。

View File

@@ -0,0 +1,56 @@
# 结果总览项目分析详情正式化外壳前端实施计划
## 目标
- 基于 `output/mockups/project-analysis-formal-soft-preview.html` 的静态预览稿,恢复“项目分析详情”弹窗的正式化、去卡片化外壳样式。
- 本次仅调整详情弹窗整体框架、标题区、左侧人物档案区、右侧主承载区与页签外层视觉。
- 不修改“异常明细”页签内部业务结构、分页、按钮、接口与数据逻辑。
## 范围
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
## 实施方案
### 1. 弹窗外壳正式化
- 将当前偏渐变、大圆角的详情弹窗外壳改为更平直的正式化工作台样式。
- 顶部保留“结果总览 / 项目分析详情”的信息层级,但改成浅边线、弱装饰、明确留白的标题区。
- 调整整体布局间距,让左侧档案区和右侧主区以纵向分隔线形成清晰结构。
### 2. 左侧档案区映射静态稿
- 保留当前姓名、风险等级、工号、部门、所属项目、命中模型数、核心异常标签的数据字段。
- 通过信息头、字段列表、摘要区三段式样式,映射静态稿的人物档案视觉。
- 不新增额外字段、不新增辅助业务区块。
### 3. 右侧主区外层收口
- 保持 `el-tabs`、错误提示、加载逻辑、默认页签逻辑不变。
- 只调整页签外层、内容承载区、主区边界与留白,不进入各 tab 内部重做内容样式。
## 验证计划
### 代码校验
-`ruoyi-ui` 目录执行:
- `node tests/unit/project-analysis-dialog-layout.test.js`
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
### 浏览器验证
- 先通过 `nvm use` 确认前端 Node 版本。
- 启动真实前端页面后,使用 `browser-use` 在系统真实页面打开“项目分析详情”弹窗。
- 重点核对:
- 标题区是否为正式化平直样式
- 左侧档案区是否按预览稿形成清晰三段层次
- 右侧主区是否只改外层、不影响“异常明细”内部内容与交互
## 风险控制
- 不改接口、不改 mock 数据、不改异常明细内部组件,避免把外壳样式改动扩大成业务结构调整。
- 单测只更新与外层视觉契约直接相关的断言,避免引入无关回归。

View File

@@ -0,0 +1,24 @@
# 2026-05-06 项目分析个人详情页样式对齐前端实施计划
## 目标
- 将项目分析个人详情页样式对齐到用户提供的参考图。
- 本次只调整前端样式表现,不改接口、字段、交互逻辑和业务内容。
## 范围
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue`
## 实施要点
- 调整详情弹窗头部、左右分栏比例、页签尺寸和间距。
- 调整左侧人物档案与命中模型摘要区块的标题、信息行、风险徽标和标签样式。
- 调整右侧异常明细内容区的区块标题、表格头部、单元格留白、异常对象摘要卡片和快照块样式。
- 保持现有数据绑定、页签切换、证据库按钮和分页逻辑不变。
## 验证
- 在真实业务页面打开项目总览详情弹窗,检查个人详情页视觉是否与参考图一致。
- 确认异常明细、对象摘要、加入证据库按钮和分页仍可正常显示。

View File

@@ -0,0 +1,29 @@
# Fund Graph Frontend Implementation Plan
**目标:** 在专项排查页签落地完整版图谱工作台,在项目分析弹窗内落地简版图谱展示,支持查看一层资金流和关系图谱,并通过点击资金边查看代表性流水。
**一期范围:**
- 专项排查版保留搜索栏:身份证号/员工姓名、交易时间范围、最小汇总金额。
- 项目分析弹窗版不提供搜索栏与手工新增入口,使用当前人员自动定位图谱。
- 图谱区:用 ECharts force graph 展示人员、企业、账号代理、名称代理节点和有向资金边。
- 汇总区:展示节点数、资金边数、交易笔数、汇总金额。
- 明细区:点击任意边后展示累计金额、交易笔数、最近交易和关系标签。
- 专项排查版保留分页逐笔流水;项目分析弹窗版仅展示最近 5 条代表性流水。
**实现内容:**
- 新增 `src/api/ccdi/graph/fundGraph.js``src/api/ccdi/graph/relationGraph.js`,封装图谱接口。
- 新增 `ProjectAnalysisFundFlowTab.vue`,承接项目分析弹窗内的简版图谱展示。
- 新增 `graph/FundGraphSection.vue`,统一承载完整版和弹窗简版两种模式。
- 修改 `ProjectAnalysisDialog.vue``SpecialCheck.vue`,分别接入简版与完整版图谱组件。
**交互口径:**
- 打开页签时优先使用模型摘要或人员对象中的身份证号/姓名自动查询。
- 专项排查版允许手工输入身份证号或员工姓名重新查询,并支持手工新增资金流向。
- 项目分析弹窗版保留图、基础节点详情、边汇总和轻量明细,不保留搜索、手工新增、疑似企业弹层和复杂操作。
- 默认展示 Top 20 资金边,避免一次渲染过多边影响交互。
- 一期不自动展开追溯层级,节点“一层展开”通过追加一圈节点和边 merge 回现有图谱。
**后续追溯口子:**
- 当前图谱节点已保留原始 `nodeKey``canExpand`
- 未来可在节点点击事件中调用后端追溯接口,把新增节点和边合并进现有图谱。
- 组件已按一层查询和边明细查询拆分,后续追溯不会影响“点边看流水”的核心链路。

View File

@@ -0,0 +1,21 @@
# 结果总览弹窗资金流向逐笔流水展示前端实施计划
## 需求范围
- 修改结果总览“查看详情”弹窗中的“资金流向”页签。
- 去掉资金边详情中的“弹窗速览”提示文案。
- 在弹窗资金边详情中展示逐笔流水明细,并保持分页加载。
- 不修改专项排查页的图谱入口和完整图谱展示逻辑。
## 实施方案
1. 调整 `ProjectAnalysisFundFlowTab.vue` 中传给 `FundGraphSection` 的参数,开启资金边逐笔流水表格。
2. 保持 `FundGraphSection` 现有边明细接口调用逻辑不变,继续使用分页查询。
3. 在弹窗包装组件内改为上方图谱、下方逐笔流水布局,收敛表格和分页样式,避免逐笔流水表撑高或挤压图谱画布。
4. 不新增接口、不修改后端、不改变专项排查页完整下钻能力。
## 验证计划
- 前端构建前按项目规则执行 `nvm use` 并确认 Node 版本。
- 执行前端构建或聚焦测试,确认组件编译通过。
- 使用真实页面打开结果总览“查看详情”弹窗,切换到“资金流向”,点击资金边确认下方显示逐笔流水和分页。

View File

@@ -0,0 +1,36 @@
# 结果总览弹窗资金流向可用性优化前端实施计划
## 目标
- 优化结果总览“查看详情”弹窗内“资金流向”页签的图谱展示空间。
- 降低多条资金边金额标签、节点名称在小画布中重叠的问题。
- 在查看单个对手方资金边明细后,通过点击图谱画布空白区域恢复全量图谱状态。
## 实施范围
- 仅调整结果总览“查看详情”弹窗中的资金流向图谱。
- 不调整专项排查页资金图谱的默认尺寸和业务逻辑。
- 不修改后端接口、数据库和资金流水分页接口。
## 实施方案
1.`ProjectAnalysisFundFlowTab` 中扩大资金流向工作区尺寸:
- 提高弹窗内图谱卡片高度。
- 改为上方图谱、下方逐笔流水布局,给图谱保留更大画布空间。
- 下方逐笔流水表格保持固定高度和分页展示,避免撑高弹窗。
2.`FundGraphSection` 中增加弹窗可配置能力:
- 增加边标签紧凑展示开关,金额使用“万/亿”等短格式展示。
- 支持隐藏资金边汇总卡片,只保留逐笔流水明细。
- 点击图谱画布空白区域时清空选中节点、选中边和逐笔流水明细,并重新渲染图谱。
3. 优化选中状态表达:
- 图谱中当前边和两端节点保持高亮,其他边降低透明度。
- 点空白区域后恢复初始全量图谱状态,不额外增加按钮。
## 验证计划
- 执行前端构建,确认无编译错误。
- 在真实页面进入结果总览“查看详情”弹窗,切换到“资金流向”。
- 选择包含多笔交易金额标签的资金边,验证节点名称和金额标签不再严重叠加,逐笔流水显示在图谱下方。
- 点击图谱画布空白区域,验证逐笔流水清空,图谱恢复全量状态。

View File

@@ -0,0 +1,609 @@
# 图谱开发决策记录
记录当前已确认的资金流图谱和关系图谱开发口径,作为后续开发、验收和跨对话延续的依据。
## 1. 页面嵌入位置
- 图谱功能先嵌入项目详情页的“专项排查”页签。
- 现有前端入口为 `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- 页面在项目详情内承载,但资金流图谱本身不按 `project_id` 过滤。
- 查询入口以全局身份证号 `cret_no` 或员工姓名为准。
## 2. 图谱表结构原则
- 建表逻辑尽量保持图谱平台已验证过的 SQL 逻辑。
- 不重新设计统一点表/边表。
- 表名保留图谱平台 SQL 中的五张结果表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_sum_edge`
- 不在五张图谱表中增加 `project_id` 作为查询过滤口径。
- 一条流水可能存在于多个项目中,资金流图谱按全局资金关系构建,避免项目维度导致重复建点或重复算边。
## 3. 五张表职责
| 表名 | 作用 |
| --- | --- |
| `lx_fund_flow_subject_node` | 主体点,表示人员、企业、名称代理主体 |
| `lx_fund_flow_account_node` | 账户点,表示具体账号或名称代理账户 |
| `lx_fund_flow_own_account_edge` | 主体到账户的持有关系 |
| `lx_fund_flow_detail_edge` | 账户层逐笔资金交易边 |
| `lx_fund_flow_sum_edge` | 主体层资金汇总边,前端默认展示 |
关键点:
- 一个人可能有多个账号,所以必须保留主体点、账户点、持有边三层结构。
- 前端默认展示主体层汇总边,不默认展示全部账户层明细边,避免节点过多。
- 点击汇总边后,再查询账户层逐笔流水。
## 4. 构建逻辑
构建逻辑以 `tupu/资金流图谱代码/lanxi_liushui_no_relation_simplified.sql` 为主。
重要口径:
- 项目内 SQL 尽量和图谱平台原 SQL 保持一致。
- 先导入一部分“所有员工流水明细”作为图谱基座。
- 这部分基座数据视为已验证、绝对正确,不应被后续构建流程随意清空或覆盖。
- 后续从 `ccdi_bank_statement` 拉取新增流水时,需要先和图谱基座做一致性判断。
- 如果 `ccdi_bank_statement` 中的流水已经能在图谱基座中匹配到一致流水,则不同步进图谱,避免重复计入。
- 如果没有匹配到一致流水,则按原图谱 SQL 逻辑增量插入图谱。
保留的核心口径:
- 本方证件号 `cret_no` 必须存在。
- 对手方名称必须存在,空值、空串、`0` 过滤。
- 金额必须有效,支出和收入统一成 `amount``flag`
- 支出 `flag = 1`,收入 `flag = 2`
- 明细去重,避免重复流水导致金额和笔数翻倍。
- 同名归并只作用于主体层,不改变账户节点和账户明细边。
- 无账号但有名称的对手方,按原 SQL 逻辑生成名称代理账户和名称代理主体。
一期先不展开追溯能力。需要为后续追溯预留字段和逻辑口子:
- `source_table`
- `penetrate_level`
- 后续可扩展 `FIRST``LEVEL1` 等来源。
## 4.1 基座与增量同步口径
图谱表不是“清空重建”的临时结果表,而是承载已验证图谱基座和后续增量的正式结果表。
基座数据:
- 来源为先导入的所有员工流水明细。
- 按原图谱平台 SQL 生成五张 `lx_*` 表。
- 基座数据作为可信结果保留。
增量数据:
- 来源为后续 `ccdi_bank_statement`
- 拉取后先按原 SQL 的流水标准化和去重口径生成候选流水。
- 候选流水和既有 `lx_fund_flow_detail_edge` 做一致性比对。
- 已存在一致流水时跳过,不插入图谱。
- 不存在一致流水时,再增量生成账户点、主体点、持有边、明细边和汇总边。
一致性比对建议使用稳定业务特征,而不是 `project_id`
- 本方账号
- 本方户名
- 对手方账号
- 对手方户名
- 交易日期
- 金额
- 收支方向 `flag`
- 摘要 `user_memo`
- 银行流水号或交易流水号,如果有
增量插入要求:
- 点表按 `object_key` 去重插入。
- 持有边按 `object_key` 去重插入。
- 明细边先判重,未存在才插入。
- 汇总边需要按主体对和方向重新聚合或局部 upsert不能简单追加导致金额翻倍。
## 4.2 ODPS 基座同步到 MySQL
当前真实部署口径:
- 原图谱 SQL 已在 ODPS 中有一份结果。
- ODPS 结果只涉及行内流水。
- ODPS 已经产出五张图谱结果表。
- 可以先将 ODPS 中五张结果表一次性同步到纪检 MySQL。
- MySQL 同步建表脚本记录在 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- 生产数据库表结构变更由人工单独执行,不跟随测试环境或应用发布自动更新。
- 项目内保留 SQL 文件,用于本地验证、评审和生产手动执行参考。
同步后的 MySQL 五张表继续使用原图谱表名:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_sum_edge`
同步要求:
- ODPS 到 MySQL 首次同步只做基座装载。
- 基座装载完成后,后续不再通过清空五张表重建处理。
- ODPS 字段和 MySQL 字段同名的按显式字段列表导入。
- MySQL 侧新增字段如 `family_relation_type``summary_object_key``source_table``penetrate_level``bank_statement_id``bank_trx_number` 可为空。
- `lx_fund_flow_sum_edge.detail_ids` 在 MySQL 中使用 `LONGTEXT` 接收 ODPS ARRAY 同步后的 JSON 或字符串表示,前后端查询不强依赖该字段。
## 4.3 MySQL 后续增量方式
后续新增数据都在纪检 MySQL 内处理:
- 来源表为 `ccdi_bank_statement`
- 增量逻辑从 `ccdi_bank_statement` 抽取候选流水。
- 候选流水按原图谱 SQL 口径标准化、过滤、生成 object_key。
- 先和既有 `lx_fund_flow_detail_edge` 做一致性判重。
- 已存在一致流水时不插入图谱。
- 不存在一致流水时,才增量插入点、账户、持有边、明细边。
- 汇总边 `lx_fund_flow_sum_edge` 按主体对和方向重新聚合或局部 upsert。
调度建议:
- 一期建议做每日定时调度,不建议一开始做实时。
- 推荐使用 RuoYi/Quartz 定时任务,每天凌晨或低峰期执行。
- 同时保留后台手动触发能力,便于首次补跑、排查和修复。
- 实时同步不是不能做,但没有必要优先做;实时会增加事务、锁、重复判断和汇总边更新复杂度。
推荐执行节奏:
1. ODPS 五张结果表一次性同步到 MySQL。
2. MySQL 跑一次家庭关系补充和 `summary_object_key` 回填。
3. 每日 Quartz 调度从 `ccdi_bank_statement` 抽取新增候选流水。
4. 候选流水与 `lx_fund_flow_detail_edge` 判重。
5. 未命中重复的流水增量入图。
6. 更新对应主体层汇总边。
7. 前端始终只基于 MySQL 五张 `lx_*` 图谱表查询展示。
## 4.4 数据库变更执行边界
数据库表结构改动属于生产库手工变更,不纳入测试环境自动更新。
后续开发分工:
- SQL 文件由代码库保留,作为生产手工执行依据和本地验证依据。
- 生产执行由人工确认后单独处理。
- 后端开发默认这些表在目标库中已经存在。
- 前端开发不感知数据库变更,只调用后端接口。
- 测试环境如没有这五张表,需要手动执行 SQL 后再联调。
- 应用发布包不自动执行这些 DDL。
## 5. 家庭关系
家庭关系是本次项目内新增能力,参考 `tupu/资金流图谱代码/资金流图谱_家庭关系补充.sql`
处理原则:
- 不改变资金方向。
- 不改变主体归并逻辑。
- 不改变账户层明细边生成逻辑。
- 只在资金边上增加家庭关系标注。
匹配规则:
- 交易任意一侧可映射到员工主体。
- 员工主体必须有身份证号。
- 对手方姓名命中 `ccdi_staff_fmy_relation.relation_name`
- 同一员工和同一关系人姓名只有一个 `relation_type` 时才标注。
- 多个关系类型冲突时不打标,避免误判。
建议补充字段:
- `lx_fund_flow_detail_edge.family_relation_type`
- `lx_fund_flow_sum_edge.family_relation_type`
## 6. 查询逻辑
搜索主体:
```sql
select *
from lx_fund_flow_subject_node
where idnocfno = #{keyword}
or name like concat('%', #{keyword}, '%');
```
查询主体层资金图:
```sql
select *
from lx_fund_flow_sum_edge
where from_key = concat('idno_node/', #{objectKey})
or to_key = concat('idno_node/', #{objectKey})
order by amount desc, total_trans_cnt desc
limit #{limit};
```
点击汇总边查询逐笔流水:
```sql
select *
from lx_fund_flow_detail_edge
where summary_object_key = #{sumObjectKey}
order by trx_date desc
limit #{offset}, #{pageSize};
```
说明:
- 原图谱平台 `detail_ids` 可以保留。
- MySQL 分页查询建议增加 `summary_object_key`,用于从汇总边直接查明细边。
- `summary_object_key` 是查询优化字段,不改变原图谱平台点边模型。
## 7. 前后端开发边界
后端负责:
- 从五张 `lx_*` 图谱结果表读取数据。
- 按身份证号或姓名定位主体。
- 返回主体层图谱节点和汇总边。
- 支持点击资金汇总边分页查询逐笔流水。
- 透出家庭关系字段。
- 空表或脏数据时返回空结果,不让前端报错。
前端负责:
- 在“专项排查”页签中呈现图谱展示区域。
- 支持身份证号或员工姓名搜索。
- 支持“资金图谱 / 关系图谱”页签。
- 一期资金图谱做实,关系图谱可先保留入口。
- 默认展示主体层资金汇总图。
- 点击边展示逐笔流水明细。
- 展示图谱明细边中已写入的家庭关系标签,如配偶、父母、子女。
## 8. 基座保护与异常数据处理
图谱表承载已验证基座,不按“可随意清空重建”设计。人工可以维护或清理异常数据,但默认应保护已有基座。
处理要求:
- 后续增量同步不得清空五张 `lx_*` 表。
- 增量同步前必须先判断候选流水是否已存在于 `lx_fund_flow_detail_edge`
- 已存在一致流水时跳过,避免重复金额和重复笔数。
- 人工清理异常边后,后端查询需要能容忍局部缺失数据。
- 边表有、点表缺失时,后端过滤无法匹配节点的边,不让前端报错。
- 明细边为空时,点击汇总边提示暂无逐笔流水。
- 如果人工确实清理了部分图谱数据,后续增量插入仍需按 `object_key` 和流水一致性规则防重。
## 9. UI 风格
固定设计口径:
- 浅色系统风格。
- 正式后台质感。
- 与当前纪检系统色调保持一致,蓝、白、灰为主。
- 朝图谱平台式交互靠齐。
- 不做黑色大屏。
- 不做网感、霓虹、炫光科技风。
- 图谱画布使用浅灰白背景,边界清楚。
- 搜索区放在顶部,支持身份证号和姓名。
- 页签为“资金图谱”和“关系图谱”。
- 明细使用右侧抽屉或下方面板展示,优先保证字段清楚和分页性能。
## 10. 后续开发顺序
建议顺序:
1. 先落项目内 SQLDDL、构建 SQL、家庭关系补充 SQL、索引 SQL。
2. 先支持已导入员工流水明细作为图谱基座。
3. 增加 `ccdi_bank_statement` 到图谱表的增量同步逻辑。
4. 增量同步必须先和既有 `lx_fund_flow_detail_edge` 做一致性判重,已存在则不同步。
5. 后端接口改为读取五张 `lx_*` 图谱表。
6. 前端在“专项排查”页签接入图谱展示区域。
7. 完成资金图谱搜索、展示、点击边查明细。
8. 增加家庭关系标签展示。
9. 验证基座保护、增量防重、一个人多个账号、家庭关系命中等场景。
## 11. 当前代码进度与偏差
截至 2026-05-28项目内已经做过一版一期资金流图谱代码但这版实现口径与当前最终方案不完全一致后续需要重构而不是直接当最终版。
已完成过的代码:
- 后端新增 `CcdiFundGraphController`,接口路径为 `/ccdi/project/fund-graph/graph``/ccdi/project/fund-graph/edge-detail`
- 后端新增 DTO/VO`CcdiFundGraphQueryDTO``CcdiFundGraphEdgeDetailQueryDTO``CcdiFundGraphVO``CcdiFundGraphNodeVO``CcdiFundGraphEdgeVO``CcdiFundGraphStatementVO`
- 后端新增 Mapper 和 Service`CcdiFundGraphMapper``ICcdiFundGraphService``CcdiFundGraphServiceImpl``CcdiFundGraphMapper.xml`
- 前端新增接口文件 `ruoyi-ui/src/api/ccdi/fundGraph.js`
- 前端新增组件 `ProjectAnalysisFundFlowTab.vue`
- 前端已在 `ProjectAnalysisDialog.vue` 中接入资金流图谱页签。
旧版已验证情况:
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- `npm run build:prod` 通过。
- 真实库只读校验过项目 33 和姓名样例,能查出资金边,点击边能查逐笔流水。
- 曾修正 MySQL 8 保留词别名 `rows` 和字符集排序规则不一致问题。
当前偏差:
- 旧版接口是实时从 `ccdi_bank_statement` 聚合资金图谱,不读取五张 `lx_*` 图谱结果表。
- 旧版查询带项目上下文,当前最终口径是不按 `project_id` 过滤,以全局 `cret_no` 或姓名为入口。
- 旧版前端接在项目分析弹窗 `ProjectAnalysisDialog`,当前最终入口应放在“专项排查”页签。
- 旧版没有按图谱平台五表基座和增量同步口径处理。
- 旧版没有家庭关系标注。
后续处理原则:
- 可复用旧版的图谱展示、点击边查明细、分页表格等前端交互经验。
- 可复用旧版 DTO/VO 中适合前端展示的字段,但字段来源需要改为五张 `lx_*` 表。
- 后端 SQL 必须从实时聚合 `ccdi_bank_statement` 改为读取 `lx_fund_flow_subject_node``lx_fund_flow_account_node``lx_fund_flow_own_account_edge``lx_fund_flow_detail_edge``lx_fund_flow_sum_edge`
- 前端入口需要从项目分析弹窗迁移或重做到“专项排查”页签。
- 旧版文件在重构时应谨慎处理,避免影响当前项目分析弹窗已有功能。
## 12. 页面查询与汇总表最新决策
最新决策:
- 纪检平台资金流图谱页面不强依赖 `lx_fund_flow_sum_edge`
- 页面查询以 `lx_fund_flow_detail_edge` 为事实表,由后端按当前查询条件实时聚合。
- 前端不做金额和笔数聚合,只负责渲染后端返回的节点、边和明细。
- `lx_fund_flow_sum_edge` 如生产侧不需要兼容图谱平台页面,可以不作为纪检页面必需表。
- 如果 ODPS 已有 `lx_fund_flow_sum_edge`,可以选择不同步到 MySQL或同步后仅作为参考缓存不作为纪检页面查询依据。
原因:
- 用户每次查询通常以一个人为中心,一跳图谱范围可控。
- 用户需要按 `trx_date` 任意筛选时间范围。
- 全量 `lx_fund_flow_sum_edge` 不能准确表达任意时间段内的金额和笔数。
- 每天新增明细后维护汇总表会增加复杂度。
- 后端从明细边实时聚合能保证筛选结果准确,且比前端聚合更可靠。
后端聚合口径:
1. 用身份证号、姓名或 `object_key` 定位主体点。
2. 查询该主体名下账户。
3.`lx_fund_flow_detail_edge` 查询这些账户相关流水。
4.`trx_date`、金额、方向、家庭关系等筛选条件过滤。
5. 后端将账户层明细边聚合为主体层资金边。
6. 返回前端用于图谱展示。
时间筛选字段:
- 所有时间筛选基于 `lx_fund_flow_detail_edge.trx_date`
- 不用 `lx_fund_flow_sum_edge.first_trx_date``lastest_trx_date` 判断筛选结果。
## 13. 节点穿透最新决策
节点穿透以 `lx_fund_flow_subject_node.object_key` 为唯一标识,不按姓名穿透。
口径:
- 实名主体按原 SQL 逻辑生成 `object_key`,即 `md5(trim(idnocfno))`
- 用户点击节点后,可选择“以此节点为中心查询”或“展开此节点”。
- 默认不自动穿透,避免图谱过长和误展开。
- 后端按被选节点的 `object_key` 查询其账户和流水。
- 节点是否可穿透由后端返回 `canExpand` 控制。
允许穿透:
- 有明确身份证号或证件号的实名主体。
- 有明确账户归属、能通过 `lx_fund_flow_own_account_edge` 找到账户的主体。
默认不穿透:
- 只有名称、没有证件号、没有明确账户归属的名称代理主体。
- 无法通过 `object_key` 准确定位账户集合的节点。
交互建议:
- 一期做“设为中心查询”,即点击节点后重新以该节点为中心画一跳图。
- 后续再做“在当前图上追加展开”,避免一期图谱状态管理过复杂。
## 14. 最终减法版决策
本节覆盖前文早期关于五张表、`lx_fund_flow_sum_edge`、关系图谱页签的旧设想。后续开发以本节为准。
本节为 2026-05-28 资金流图谱减法版决策,重点是不依赖 `lx_fund_flow_sum_edge`。关于关系图谱页签,后续已在第 16 节更新为“保留关系图谱能力”,以第 16 节为准。
必要表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
不依赖:
- `lx_fund_flow_sum_edge`
页面查询:
- 输入身份证号、姓名或点击节点 `object_key`
- 后端定位主体点。
- 后端查询主体持有账户。
- 后端从 `lx_fund_flow_detail_edge` 按账户、`trx_date`、金额等条件实时聚合资金边。
- 前端只渲染后端返回的资金节点、资金边和逐笔明细。
家庭关系:
- 只作为资金流图谱中的标签展示。
- 家庭关系识别在图谱构建或数据加工阶段完成,并写入 `lx_fund_flow_detail_edge.family_relation_type`;后端查询接口只读取并返回该字段,不实时匹配家庭表。
- 如果生产构建需要按对手方户名匹配 `ccdi_staff_fmy_relation.relation_name`,应在构建 SQL 中完成,并控制同名误判风险。
- 有明确 `relation_cert_no` 的家庭关系人按实名主体处理,`object_key = md5(trim(relation_cert_no))`
- 用户点击该节点时,可按该节点 `object_key` 设为中心继续查询。
测试数据:
- 测试 DDL 为 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- 测试数据脚本为 `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`
- 测试数据只写四张必要表。
- 测试数据来源于 dev 库 `ccdi_bank_statement``ccdi_staff_fmy_relation`
- 原始流水表不被修改。
## 15. 前后端与页面交互设计
默认查询:
- 默认查询全部流水,不默认带交易日期过滤。
- 用户选择交易日期后,后端才按 `lx_fund_flow_detail_edge.trx_date` 过滤。
- 时间过滤不查汇总表,直接从明细边实时聚合。
后端接口建议:
```text
GET /ccdi/project/fund-graph/search
GET /ccdi/project/fund-graph/graph
GET /ccdi/project/fund-graph/edge-detail
POST /ccdi/project/fund-graph/manual-edge
GET /ccdi/project/relation-graph/search
GET /ccdi/project/relation-graph/graph
GET /ccdi/project/relation-graph/suspected-enterprises
```
接口职责:
- `search`:按身份证号或姓名查主体点,返回候选主体列表。
- `graph`:按主体 `object_key` 查询一跳资金图,默认全部流水,可选日期、金额、方向过滤。
- `edge-detail`:点击资金边后,分页查询该边下的逐笔流水。
`graph` 入参:
```text
objectKey 必填,主体 object_key
startDate 可选,交易开始日期
endDate 可选,交易结束日期
amountMin 可选,最小金额
amountMax 可选,最大金额
direction 可选1支出、2收入
limit 可选默认20
```
`graph` 返回:
```text
centerNode 当前中心主体
nodes 图谱节点
edges 聚合后的资金边
summary 当前查询范围的总金额、总笔数、家庭关系边数量
```
节点字段:
```text
objectKey
nodeKey idno_node/{object_key}
name
idNo
nodeType PERSON / PROXY
canExpand
relationType 如果是家庭关系节点,返回配偶/父亲/母亲等
```
边字段:
```text
edgeKey
fromKey
toKey
fromName
toName
direction 1支出、2收入
amount
transactionCount
firstTrxDate
lastTrxDate
familyRelationType
```
`edge-detail` 入参:
```text
fromObjectKey
toObjectKey
direction
startDate
endDate
pageNum
pageSize
```
页面交互:
- 页面位置:项目详情“专项排查”页签。
- 页面标题:图谱展示。
- 展示“资金流图谱”和“关系图谱”两个页签。
- 搜索区只保留必要控件:身份证号/姓名、交易日期、查询、重置。
- 默认空态提示:请输入身份证号或姓名查询资金流图谱。
- 查询后画一跳图,中心节点为当前人员。
- 边上只显示金额和笔数,家庭关系用标签显示。
- 点击资金边,右侧抽屉展示逐笔流水。
- 点击节点,提供“设为中心查询”;默认不自动穿透。
- 只有 `canExpand = true` 的节点展示“设为中心查询”。
性能口径:
- 前端不聚合金额和笔数。
- 后端只围绕一个主体的账户集合查明细边。
- 默认全部流水也只查当前主体相关边,不扫全表。
- 必须使用 `lx_fund_flow_own_account_edge.from_key``lx_fund_flow_detail_edge.from_key/trx_date``lx_fund_flow_detail_edge.to_key/trx_date` 索引。
- 后端 SQL 参数比较需要显式 `COLLATE utf8mb4_general_ci`,避免当前库连接排序规则和表排序规则不一致。
当前 dev 测试数据:
```text
测试身份证号617673198109148314
测试 object_key以 `MD5('617673198109148314')` 为准
主体点10
账户点14
持有边14
明细边72
```
测试覆盖:
- 默认全部流水聚合。
- 日期范围筛选聚合。
- 支出方向 `flag = 1`
- 收入方向 `flag = 2`
- 家庭关系标签:配偶、父亲、母亲。
- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。
- 点击家庭关系节点按 `object_key` 设为中心查询。
## 16. 2026-05-29 最新验收口径
本节为当前最新口径,用于覆盖前文早期变更记录中的冲突描述。
当前图谱功能保留两类能力:
- 资金流图谱:作为专项排查中的核心图谱能力,读取 `lx_fund_flow_subject_node``lx_fund_flow_account_node``lx_fund_flow_own_account_edge``lx_fund_flow_detail_edge`,并叠加 `lx_fund_flow_manual_edge` 手工资金流向。
- 关系图谱:保留页面页签和接口能力,读取 `lx_rel_node``lx_rel_family_edge``lx_rel_stock_edge``lx_rel_represent_edge`,支持家庭关系、股东持股、法定代表人关系和疑似同名企业召回。
当前页面入口:
- 项目详情“专项排查”页签展示完整图谱工作台,包含“资金流图谱”和“关系图谱”两个页签。
- 项目分析弹窗“资金流向”页签展示简版资金流图谱。
- 项目分析弹窗“关系图谱”页签展示简版关系图谱。
当前接口入口:
```text
GET /ccdi/project/fund-graph/search
GET /ccdi/project/fund-graph/graph
GET /ccdi/project/fund-graph/edge-detail
POST /ccdi/project/fund-graph/manual-edge
GET /ccdi/project/relation-graph/search
GET /ccdi/project/relation-graph/graph
GET /ccdi/project/relation-graph/suspected-enterprises
```
当前数据库执行口径:
- 新环境可参考 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql``sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`
- 已建资金流图谱表的环境使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引,不删除、不重建、不清空基座数据。
- 已建关系图谱表的环境使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑补字段和补索引。
- 生产 DDL 和补充 SQL 都由人工确认后手动执行,不随应用发布自动执行。
当前验收样例:
```text
资金流图谱测试身份证号617673198109148314
关系图谱测试身份证号330101198001010011
```

View File

@@ -0,0 +1,98 @@
# 图谱生产数据库手工变更清单
本清单只记录资金流图谱涉及的生产数据库表结构和数据准备事项。该部分由人工在生产库手动执行,不随应用发布自动执行,也不要求测试环境自动更新。
## 1. DDL 脚本
生产建表脚本:
```text
sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql
```
当前减法版创建五张资金流图谱必要表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
不创建、不依赖 `lx_fund_flow_sum_edge`。资金图谱页面由后端基于 `lx_fund_flow_detail_edge.trx_date` 按当前查询条件实时聚合真实资金边,手工资金流向汇总边单独存入 `lx_fund_flow_manual_edge`
## 2. 生产执行边界
- 生产库 DDL 由人工手动执行。
- 应用发布包不自动执行 DDL。
- 测试环境不会自动同步这些变更。
- 代码库保留 SQL 文件,只作为生产执行、评审和本地验证依据。
- 后端开发默认目标库中上述 `lx_*` 表已存在。
## 3. ODPS 基座同步
生产建表后,先从 ODPS 同步已验证的资金流图谱基座到 MySQL。
同步来源:
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_subject_node`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_account_node`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_own_account_edge`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_detail_edge`
同步原则:
- ODPS 基座是已验证数据,作为 MySQL 图谱基座保留。
- 同步时建议使用显式字段列表,不依赖 `select *`
- MySQL 侧新增字段允许为空。
- `lx_fund_flow_sum_edge` 不作为纪检资金图谱页面必要表,可不从 ODPS 同步。
- `lx_fund_flow_manual_edge` 不从 ODPS 同步,生产建表后初始为空,由纪检平台手工分析功能写入。
## 4. 后续增量
ODPS 基座同步后,后续新增流水在纪检 MySQL 内处理。
增量来源:
- `ccdi_bank_statement`
增量原则:
- 先标准化候选流水。
- 再和既有 `lx_fund_flow_detail_edge` 做一致性判重。
- 已存在一致流水,不同步进图谱。
- 不存在一致流水,才增量插入主体点、账户点、持有边、明细边。
- 不维护汇总表;页面查询时实时聚合。
## 5. 调度建议
一期建议采用每日定时任务,不建议一开始做实时。
推荐方式:
- RuoYi/Quartz 定时任务。
- 每日低峰期执行。
- 保留手动触发能力,用于补跑、排查和修复。
## 6. 前后端依赖
前后端开发依赖上述 MySQL 图谱表的查询结果。
- 前端不直接访问数据库。
- 后端接口读取 `lx_*` 表。
- 页面入口放在项目详情的“专项排查”页签。
- 资金流图谱中真实资金边基于 `lx_fund_flow_detail_edge` 实时聚合。
- 手工资金边来自 `lx_fund_flow_manual_edge`,属于主体级汇总边,只存 `from_object_key``to_object_key`,不存冗余 `from_key``to_key`;图谱展示时由后端临时拼出 `idno_node/{object_key}`,不提供逐笔流水下钻。
- 查询按全局 `cret_no`、姓名或节点 `object_key`,不按 `project_id` 过滤。
## 7. 性能和索引口径
一期资金图谱默认只查一个中心主体的一层资金边,并设置 `minTotalAmount = 1000``limit = 20`,不会默认拉全量毛刺边。
生产索引重点:
- `lx_fund_flow_subject_node``PRIMARY KEY(object_key)``idx_lx_fund_flow_subject_idnocfno(idnocfno)``idx_lx_fund_flow_subject_name(name)`
- `lx_fund_flow_own_account_edge``idx_lx_fund_flow_own_from_key(from_key)``idx_lx_fund_flow_own_to_key(to_key)`
- `lx_fund_flow_detail_edge``idx_lx_fund_flow_detail_from_date(from_key, trx_date)``idx_lx_fund_flow_detail_to_date(to_key, trx_date)``idx_lx_fund_flow_detail_from_to(from_key, to_key)`
- `lx_fund_flow_manual_edge``idx_lx_fund_flow_manual_from(from_object_key)``idx_lx_fund_flow_manual_to(to_object_key)``idx_lx_fund_flow_manual_pair_direction(from_object_key, to_object_key, direction)`
如果后续单个主体关联流水达到几十万级,再考虑增加主体级冗余字段或月度汇总表;一期不建 `sum_edge`

View File

@@ -0,0 +1,27 @@
# AGENTS.md 使用 browser-use 技能同步实施记录
## 变更内容
- 更新 `/Users/wkc/Desktop/ccdi/ccdi/AGENTS.md`
- 将页面功能开发完成后的真实页面测试要求由调用 Playwright 调整为使用 `browser-use` 技能打开浏览器测试。
- 将导入测试文件推荐目录由 `output/playwright/` 调整为 `output/browser-use/`
## 影响范围
- 影响后续代理执行前端页面功能开发后的浏览器测试方式。
- 影响后续导入测试临时产物的推荐保存目录。
- 不涉及业务代码、脚本、接口或数据库变更。
## 保存路径确认
- 本次实施记录保存路径为 `/Users/wkc/Desktop/ccdi/ccdi/docs/reports/implementation/`
- 已确认该路径为 `ccdi` 项目既有实施记录目录。
## 验证情况
- 已确认 `AGENTS.md` 中页面功能测试规则改为使用 `browser-use` 技能。
- 已确认 `AGENTS.md` 中测试产物目录改为 `output/browser-use/`
## 说明
- 历史实施记录、历史计划和历史设计文档中的 Playwright 表述保留为当时执行记录,不作为本次规则同步范围。

View File

@@ -0,0 +1,22 @@
# AGENTS.md 更新实施记录
## 修改时间
2026-04-26
## 修改内容
- 在根目录 `AGENTS.md` 中补充全局执行规则,覆盖 Git、AGENT、文档、测试与方案规范。
- 明确 `using-superpowers` 与 subagent 的启用条件及模型要求。
- 明确设计文档审查、前后端实施计划拆分、实施文档留存、Playwright 页面测试、测试文件不提交等要求。
## 影响范围
- 仅影响 AI 编码助手在本仓库内的协作规则与执行约束。
- 未修改业务代码、数据库脚本、前端页面或后端接口。
## 验证情况
- 已确认 `AGENTS.md` 位于仓库根目录。
- 已确认实施记录保存路径为 `docs/reports/implementation/`
- 本次为文档规则更新,不涉及编译、单元测试或页面测试。

View File

@@ -0,0 +1,16 @@
# nvmrc 配置实施记录
## 修改内容
- 在仓库根目录新增 `.nvmrc`,统一指定 Node 版本为 `14.21.3`
- 保留并对齐前端目录 `ruoyi-ui/.nvmrc` 的既有配置,确保在仓库根目录或前端目录执行 `nvm use` 时使用同一 Node 版本。
## 影响范围
- 仅影响本地前端开发、构建、调试命令的 Node 版本选择。
- 不涉及后端代码、数据库结构、菜单权限或业务逻辑调整。
## 验证情况
- 已检查 `ruoyi-ui/.nvmrc` 当前内容为 `14.21.3`
- 已检查 `ruoyi-ui/package.json`,当前前端为 Vue 2 / Vue CLI 4 依赖栈,适合继续使用 Node `14.21.3`

View File

@@ -0,0 +1,88 @@
# 生产服务器 Java 后端启动脚本实施记录
## 保存路径确认
- 本次新增生产服务器后端启停脚本,实施记录保存到 `docs/reports/implementation/`,符合仓库实施文档目录规范。
## 修改目标
- 新写一个可在生产服务器上运行的 Java 后端启停脚本。
- 脚本支持配置 Java Home不依赖 Maven不执行本地构建只负责运行已上传到服务器的 `ruoyi-admin.jar`
## 修改内容
- 新增 `deploy/start-java-backend-prod.sh`
- 在脚本顶部新增“生产配置区”,生产服务器上的 Java Home、Jar 目录、Profile、JVM 参数和额外应用参数均直接写在脚本文件中。
- 按生产服务器目录结构调整默认 Jar 路径:启动脚本位于外层目录,后端 Jar 位于 `backend/ruoyi-admin.jar`
- 通过脚本内 `APP_HOME="${SCRIPT_DIR}/backend"` 指定生产服务器上的 Jar 所在目录。
- 通过脚本内 `BACKEND_JAVA_HOME` 指定脚本使用的 JDK优先级高于系统 `JAVA_HOME`
- 脚本内 `BACKEND_JAVA_HOME` 留空时读取系统 `JAVA_HOME`;两者都未配置时使用 `PATH` 中的 `java`
- 支持 `start``stop``restart``status``logs` 操作。
- `start` 会先调用 `stop_backend`,通过 `ps -ef` 关闭旧后端进程,再启动新的 `backend/ruoyi-admin.jar`
- `start``restart` 在后端启动成功后会自动持续输出 `backend/logs/backend-console.log`,按 `Ctrl+C` 仅退出日志查看,不停止后端进程。
- 支持 `stop` 单独停止后端进程。
- 使用 `APP_MARKER` 标记脚本启动的新进程,停止旧进程时统一通过 `ps -ef` 扫描进程列表,匹配当前 Jar 绝对路径或生产目录下的相对路径 `backend/ruoyi-admin.jar`
- `stop` 可停止没有脚本标记但由同一 `backend/ruoyi-admin.jar` 启动的旧进程,用于覆盖生产服务器已有手工启动进程。
- 进程扫描会忽略 `<defunct>` 行,避免僵尸进程或历史残留干扰启停判断。
-`ps -ef` 执行失败,脚本会明确报错并中止旧进程扫描,避免误判为“后端未运行”。
- 默认 Spring Profile 为 `uat`,可通过 `SPRING_PROFILES_ACTIVE` 覆盖。
## 使用方式
`deploy/start-java-backend-prod.sh` 放到生产服务器,并先修改脚本顶部“生产配置区”:
```bash
BACKEND_JAVA_HOME=""
APP_HOME="${SCRIPT_DIR}/backend"
JAR_NAME="ruoyi-admin.jar"
SPRING_PROFILES_ACTIVE="uat"
JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError"
APP_ARGS=""
```
配置完成后直接执行:
常用命令:
```bash
./start-java-backend-prod.sh start
./start-java-backend-prod.sh stop
./start-java-backend-prod.sh restart
./start-java-backend-prod.sh status
./start-java-backend-prod.sh logs
```
## 验证记录
- 执行 `bash -n deploy/start-java-backend-prod.sh`
- 结果:通过
- 说明:脚本 Bash 语法正确。
- 执行 `bash deploy/start-java-backend-prod.sh help`
- 结果:通过
- 说明:帮助信息正常输出,并说明启动成功后会持续输出控制台日志。
- 执行 `rg -n "start_backend|follow_logs" deploy/start-java-backend-prod.sh`
- 结果:通过
- 说明:已确认 `start``restart` 分支均使用 `start_action`,流程为先 `stop_backend`,再 `start_backend`,最后 `follow_logs`
- 执行 `bash deploy/start-java-backend-prod.sh status`
- 结果:通过
- 说明:在允许执行 `ps -ef` 后,无后端进程时可正常输出未运行状态。
- 执行 `rg -n "pgrep" deploy/start-java-backend-prod.sh`
- 结果:无匹配
- 说明:已确认停止旧进程不再依赖 `pgrep`
- 执行 `rg -n "ps -ef" deploy/start-java-backend-prod.sh`
- 结果:通过
- 说明:已确认旧进程扫描逻辑使用 `ps -ef`
- 使用临时脚本副本和临时后端目录启动一个命令行包含 `-jar backend/ruoyi-admin.jar`、但不带脚本标记的模拟旧进程,再执行 `bash /tmp/start-java-backend-prod-test.sh stop`
- 结果:通过
- 说明:已验证 `stop` 可以停止同一 Jar 路径的旧进程,不要求旧进程必须由当前脚本启动。
- 修改临时脚本副本,将脚本内 `BACKEND_JAVA_HOME` 设置为 `/not-exist` 后执行 `bash /tmp/start-java-backend-prod-test.sh start`
- 结果:按预期失败
- 说明:脚本能在启动前拦截无效 Java Home并输出明确错误。
- 执行 `bash deploy/start-java-backend-prod.sh start`
- 结果:按预期失败
- 说明:脚本能正确解析当前 Java 命令,并在当前本地未提供 `deploy/backend/ruoyi-admin.jar` 时中止启动。
## 影响范围
- 仅新增生产服务器后端启停脚本与本实施记录。
- 不修改 Java 业务代码、数据库脚本、前端页面和现有发布包生成脚本。

View File

@@ -0,0 +1,68 @@
# 生产上线部署脚本实施记录
## 保存路径确认
- 本次为生产上线部署脚本改动,实施记录保存到 `docs/reports/implementation/`,符合仓库实施文档目录规范。
## 修改目标
- 生成一个可放在上线环境执行的部署脚本。
- 上线环境目录下已有 `backend/``frontend/` 和一个上线压缩包。
- 上线压缩包根层包含 `ruoyi-admin.jar``dist.zip`
- 执行脚本后先备份 `backend/``frontend/` 旧文件,再解压上线包并部署到对应目录,最后调用 `start-java-backend-prod.sh` 重启后端并输出日志。
## 修改内容
- 新增 `deploy/deploy-release-prod.sh`
- 默认按脚本同级目录解析 `backend/``frontend/``start-java-backend-prod.sh` 和上线压缩包。
- 使用 `/bin/sh` 写法,避免依赖 Bash 进程替换等服务器环境不一定支持的语法。
- 支持显式传入上线压缩包路径:`./deploy-release-prod.sh /path/to/ccdi_YYYYMMDD.zip`
- 未传入压缩包时,自动使用脚本同级目录下唯一的 `.zip` 文件,并排除 `dist.zip`
- 部署前将 `backend/``frontend/` 当前内容备份到 `backups/YYYYMMDDHHMMSS/`
- 解压上线包后校验根层必须存在 `ruoyi-admin.jar``dist.zip`
- 解压 `dist.zip` 后校验必须存在 `dist/index.html`
- 后端部署为覆盖 `backend/ruoyi-admin.jar`
- 前端部署为清空 `frontend/` 后复制 `dist/` 内文件到 `frontend/`
- 部署完成后执行 `bash start-java-backend-prod.sh restart`,由现有启动脚本完成后端重启并持续输出后端日志。
## 使用方式
生产环境目录结构:
```text
上线目录/
├── deploy-release-prod.sh
├── start-java-backend-prod.sh
├── backend/
├── frontend/
└── ccdi_YYYYMMDD.zip
```
执行:
```bash
./deploy-release-prod.sh
```
或显式指定压缩包:
```bash
./deploy-release-prod.sh /path/to/ccdi_YYYYMMDD.zip
```
## 验证记录
- 执行 `sh -n deploy/deploy-release-prod.sh`
- 结果:通过
- 说明:脚本 Shell 语法正确。
- 执行 `sh deploy/deploy-release-prod.sh --help`
- 结果:通过
- 说明:帮助信息正常输出。
- 使用 `/tmp` 构造最小上线目录、旧 `backend/`、旧 `frontend/`、上线压缩包和假的 `start-java-backend-prod.sh` 后执行部署脚本
- 结果:通过
- 说明:已验证旧文件备份、新 Jar 覆盖、前端 `dist/` 文件部署,以及最终调用启动脚本 `restart`
## 影响范围
- 仅新增生产上线部署脚本与本实施记录。
- 不修改 Java 业务代码、前端业务代码、数据库脚本和现有后端启动脚本。

View File

@@ -0,0 +1,78 @@
# 生产上线初始化 SQL 生成实施记录
## 保存路径确认
- 生产初始化 SQL`sql/ccdi_prod_init_20260428.sql`
- 实施记录:`docs/reports/implementation/2026-04-28-production-init-sql-implementation.md`
## 修改内容
- 新增 `sql/ccdi_prod_init_20260428.sql`,用于生产空库初始化。
- SQL 内容包含当前 `ccdi` 库最终态的 57 张表结构。
- SQL 必要数据范围:
- 若依基础配置、部门、岗位、用户、角色、菜单、角色菜单、字典、定时任务、公告。
- CCDI 默认模型参数,仅包含 `ccdi_model_param.project_id = 0` 的系统默认参数。
- 流水打标规则 `ccdi_bank_tag_rule`
- SQL 不包含运行期业务数据:
- 项目、员工、流水、导入记录、风险结果、采购事项、实体库、中介库、操作日志、登录日志等数据均保持空表。
- 将导出结构中的非规范排序规则统一修正为 `utf8mb4_general_ci`,未保留 `utf8mb4_0900_ai_ci`
- 针对生产执行时报错 `Specified key was too long; max key length is 767 bytes`,按生产要求删除旧库 767 bytes 限制下会超长的索引定义,保留字段长度、表结构和必要初始化数据不变。
- 删除的超长索引范围:
- Quartz 表中的长字符复合主键和依赖这些长字符复合键的外键索引。
- `ccdi_account_info.idx_ccdi_account_info_account_no`
- `ccdi_asset_info.idx_family_person`
- `ccdi_bank_statement.uk_bank_statement_dedup`
- `ccdi_bank_statement.idx_batch_id_account`
- `ccdi_bank_statement.c4c_bank_statement_stg_batch_id_IDX`
- `ccdi_bank_statement_tag_result.uk_ccdi_bank_tag_object_hit`
- `ccdi_enterprise_base_info.idx_enterprise_name`
- `ccdi_evidence.idx_ccdi_evidence_source`
- `ccdi_model_param.uk_project_model_param`
- `ccdi_project.idx_project_name`
## 验证情况
- 使用本机临时 MySQL 实例导入 `sql/ccdi_prod_init_20260428.sql` 验证通过。
- 导入后验证结果:
- 表数量57。
-`utf8mb4_general_ci` 表数量0。
- 基础数据行数:
- `sys_config`8。
- `sys_dept`10。
- `sys_dict_type`26。
- `sys_dict_data`98。
- `sys_menu`166。
- `sys_role`2。
- `sys_role_menu`134。
- `sys_user`3。
- `sys_job`3。
- `sys_notice`2。
- `ccdi_bank_tag_rule`35。
- `ccdi_model_param`17且全部为 `project_id = 0`
- 业务数据抽查为空:
- `ccdi_project`0。
- `ccdi_base_staff`0。
- `ccdi_bank_statement`0。
- `ccdi_file_upload_record`0。
- `ccdi_purchase_transaction`0。
- 测试完成后已关闭本机临时 MySQL 实例。
- 生产索引长度修复后,再次计算脚本内所有剩余索引长度,确认超过 767 bytes 的索引数量为 0。
- 删除超长索引后,再次使用本机临时 MySQL 实例导入验证通过:
- 表数量57。
-`utf8mb4_general_ci` 表数量0。
- `ccdi_model_param`17。
- `ccdi_bank_tag_rule`35。
- `sys_menu`166。
- `ccdi_project`0。
- `ccdi_bank_statement`0。
## 执行说明
- 目标生产库需为空库。
- 目标库字符集和排序规则建议使用:
```sql
CREATE DATABASE ccdi DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
```
- 导入时需使用 `utf8mb4` 会话字符集。

View File

@@ -0,0 +1,35 @@
# CCDI 上线压缩包生成脚本实施记录
## 修改内容
- 新增根目录脚本 `build_release_ccdi.sh`
- 脚本执行后会重新构建后端 `ruoyi-admin.jar`,并进入 `ruoyi-ui` 通过 `nvm use` 切换前端 Node 版本后执行 `npm run build:prod`
- 脚本会在根目录生成 `ccdi_YYYYMMDD.zip`,压缩包根层仅包含 `ruoyi-admin.jar``dist.zip`,不再额外包裹 `deploy` 目录。
- `.gitignore` 新增 `/ccdi_????????.zip`,避免生成的上线压缩包进入 Git。
## 影响范围
- 仅新增发布包生成脚本与忽略规则,不修改业务代码。
- 临时打包目录使用 `.deploy/ccdi-release-package/`,该目录已作为本地部署产物被 Git 忽略。
## 使用方式
```bash
./build_release_ccdi.sh
```
生成结果示例:
```text
ccdi_20260428.zip
├── ruoyi-admin.jar
└── dist.zip
```
## 验证情况
- 已执行 `sh -n build_release_ccdi.sh`,脚本语法检查通过。
- 已执行 `git diff --check`,未发现空白错误。
- 已执行 `./build_release_ccdi.sh`,后端 Maven 打包成功,前端生产构建成功,并生成 `ccdi_20260428.zip`
- 已执行 `unzip -l ccdi_20260428.zip`,确认压缩包根层仅包含 `ruoyi-admin.jar``dist.zip` 两个文件。
- 已执行 `git check-ignore -v ccdi_20260428.zip`,确认根目录上线压缩包会被 `.gitignore` 忽略。

View File

@@ -0,0 +1,40 @@
# 员工信息维护与招聘信息管理正式化外壳实施记录
## 变更日期
- 2026-04-29
## 变更范围
- 前端:`ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 前端:`ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 单测:`ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
- 单测:`ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
## 实施内容
### 1. 页面外壳调整
- 复用 `app-container``query-form``mb8` 等现有结构,只补最少样式。
-`ccdiBaseStaff``ccdiStaffRecruitment` 的查询区增加正式化外壳视觉,包括浅底、边框、留白和输入框边线统一。
- 保持搜索、重置、新增、导入、失败记录等按钮原有顺序不变。
- 为两个页面新增 `formal-table-shell`,将表格和分页收口到同一视觉区域内。
### 2. 弹窗外壳调整
- 复用员工页已有 `employee-edit-dialog``employee-detail-dialog` class只调整弹窗圆角、标题分隔线和弹窗正文背景。
- 复用招聘页现有弹窗结构,只补统一的弹窗标题区和正文背景样式。
- 未改动员工资产、历史工作经历等内部功能块结构。
### 3. 验证情况
- 单测通过:
- `node tests/unit/base-staff-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
- 现有单测异常:
- `node tests/unit/employee-asset-maintenance-layout.test.js`
- 失败原因为当前仓库源码不满足既有字符串断言 `createEmptyAssetRow(defaultPersonId = "")`,与本次外壳样式改动无关。
- 浏览器验证:
- 已使用 `browser-use` 打开 `http://localhost/prototype/staff-recruitment`,确认招聘信息管理页查询区、工具条、表格区已切换为正式化外壳,按钮仍保持原位。
- 尝试打开 `http://localhost/ccdiBaseStaff` 时,当前本地前端路由返回 404 页面,因此未能在浏览器内完成员工信息维护页真实页面验证。

View File

@@ -0,0 +1,92 @@
# 2026-04-29 批量正式化外壳样式实施记录
## 本次实施内容
本轮基于当前本地最新代码,批量将信息维护相关页面收口为统一的正式化外壳样式,继续保持“只改样式、不改内容和功能”的边界。
### 覆盖页面
- 账户库管理:`ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- 征信维护:`ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- 信贷客户实体关联:`ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- 信贷客户家庭关系:`ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- 中介库管理:
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- 招投标信息维护:`ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- 员工亲属实体关联:`ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- 员工亲属关系维护:`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- 员工调动记录:`ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
## 具体调整
### 查询区
- 将筛选区统一收进白色边框面板。
- 统一标签颜色、控件边框、控件圆角和表单项间距。
- 保留全部原始筛选条件和原始布局顺序。
### 工具条
- 为工具条增加统一白色承载面板。
- 按钮圆角统一收敛到约 4px。
- 不调整搜索、重置、新增、导入、失败记录等按钮的位置和语义。
### 表格
- 为列表页统一增加 `formal-table-shell` 外层承载。
- 收紧表头和表体留白,提升单屏显示密度。
- 统一普通列左对齐,操作列和选择列保持居中。
### 弹窗
- 编辑、详情、导入弹窗统一使用更正式的边界和浅底信息面板风格。
- 去掉原有偏演示感的悬浮和装饰感。
- 不改变弹窗中的字段组织和业务交互。
## 修正项
- 批量调整过程中,`ccdiPurchaseTransaction/index.vue` 样式块曾出现一个多余的 `}`,导致前端编译报错。
- 已在本轮内修正,重新通过真实页面检查。
- 批量将 `.mb8` 统一为 `flex` 承载后,`right-toolbar` 的“显示/隐藏 / 刷新”按钮组一度被挤到左侧。
- 已通过为各列表页补充 `.mb8 ::v-deep .top-right-btn { margin-left: auto; }` 恢复原有靠右位置。
## 验证结果
### 单测
- `node ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js` 通过
### 真实页面浏览器验证
已通过真实业务路由验证以下页面可以打开且关键外壳区域仍保持原有功能结构:
- `http://localhost/maintain/accountInfo`
- `http://localhost/maintain/creditInfo`
- `http://localhost/maintain/intermediary`
- `http://localhost/maintain/purchaseTransaction`
- `http://localhost/maintain/staffTransfer`
- `http://localhost/maintain/staffEnterpriseRelation`
- `http://localhost/maintain/staffFmyRelation`
- `http://localhost/maintain/custEnterpriseRelation`
- `http://localhost/maintain/custFmyRelation`
- `http://localhost/maintain/staffRecruitment`
验证点:
- 页面标题、搜索按钮、新增按钮、导入按钮仍可见
- 查询区与工具条仍位于原位置
- 未发生按钮左右换位
- 表格区与分页区仍按原内容结构展示
## 现有环境问题
- `staffRecruitment` 页面当前仍存在后端返回的字符集排序规则冲突报错:`utf8mb4_0900_ai_ci``utf8mb4_general_ci` 混用。
- 该问题来自现有后端/数据库环境,不是本次样式改动引入的问题。

View File

@@ -0,0 +1,101 @@
# 2026-04-29 正式化样式调整总说明
## 目标边界
- 本轮所有改动都基于当前本地最新代码进行。
- 仅调整页面与弹窗外壳样式,不改变原有内容、字段、按钮语义、交互流程和功能逻辑。
- 不参考 `2026-04-29-dev-ui-style-mixed-stash.patch` 中的结构性和功能性变动。
## 本轮纳入的页面
### 1. 项目分析详情弹窗
- 文件:
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 调整方向:
- 详情页外壳正式化、去卡片化
- 标题区更平直,人物档案区更规整
- 页签和主区承载更克制
- 不变内容:
- 异常明细、资产分析、征信摘要等业务内容结构不变
- 数据请求、分页、按钮逻辑不变
### 2. 员工信息维护页
- 文件:
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 调整方向:
- 筛选区收进统一白色区域
- 工具条按钮外观更正式,圆角收小
- 表格与分页统一收进正式信息面板
- 表格更紧凑、阅读更集中
- 编辑/详情弹窗外壳更像正式信息面板
- 不变内容:
- 查询字段、按钮顺序、导入入口、资产信息与党员信息功能不变
### 3. 招聘信息管理页
- 文件:
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 调整方向:
- 筛选区、工具条、表格区统一正式化
- 按钮、输入框、下拉框视觉更稳重
- 表格行高与表头高度适当收紧
- 弹窗外壳更克制
- 不变内容:
- 招聘类型、历史工作经历、导入入口、按钮位置和业务流程不变
### 4. 批量推进的信息维护页面
- 文件:
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
- 调整方向:
- 查询区、工具条、列表区统一成正式化白色信息面板
- 输入框、下拉框、日期控件边框与圆角统一收敛
- 表格与分页通过 `formal-table-shell` 统一承载
- 中介库的搜索、列表、详情、编辑、导入弹窗统一到相同视觉语言
- 各列表维护页的弹窗边界与留白更克制
- 不变内容:
- 按钮顺序、字段结构、导入流程、失败记录入口、详情内容和业务逻辑不变
## 统一视觉原则
- 筛选区更规整:统一白色面板承载,结构清晰
- 按钮更正式:圆角约 4px弱化轻飘感
- 表单控件更稳重:圆角更小,边框更统一
- 表格更紧凑:降低表头和行内容留白,一屏展示更多信息
- 列表阅读性更好:尽量左对齐,减少长字段换行和大片空白
- 视觉装饰收敛:移除不必要的阴影、渐变、悬浮感
- 卡片感减弱:边界、留白、圆角更克制,保留原有内容结构
## 验证说明
- 项目分析详情弹窗已完成真实页面验证
- 员工信息维护页已完成源码与单测级校验
- 招聘信息管理页和批量推进页面已通过真实业务路由验证:
- `http://localhost/maintain/staffRecruitment`
- `http://localhost/maintain/accountInfo`
- `http://localhost/maintain/creditInfo`
- `http://localhost/maintain/intermediary`
- `http://localhost/maintain/purchaseTransaction`
- `http://localhost/maintain/staffTransfer`
- `http://localhost/maintain/staffEnterpriseRelation`
- `http://localhost/maintain/staffFmyRelation`
- `http://localhost/maintain/custEnterpriseRelation`
- `http://localhost/maintain/custFmyRelation`
- 浏览器验证过程中发现并修复了 `ccdiPurchaseTransaction/index.vue` 的样式编译错误
- `staffRecruitment` 页面仍存在现有数据库字符集排序规则冲突报错,该问题不属于本轮样式改动

View File

@@ -0,0 +1,44 @@
# 结果总览项目分析详情正式化外壳实施记录
## 变更日期
- 2026-04-29
## 变更范围
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
## 实施内容
### 1. 弹窗外壳样式正式化
- 调整 `ProjectAnalysisDialog.vue` 的外层壳样式,去掉旧版渐变大圆角卡片视觉。
- 将弹窗主体改为浅灰外层背景 + 白色内容工作台,强化边线、留白和分栏结构。
- 将标题区改成“结果总览 / 项目分析详情”的正式化层级,保留当前命中模型提示,但收口为弱装饰信息块。
- 调整右侧主区与左侧档案区之间的分隔线和间距,只改外壳,不进入各 tab 内部内容结构。
### 2. 左侧人物档案区样式映射
- 调整 `ProjectAnalysisSidebar.vue`,按“人物档案 + 命中模型摘要”两段式结构重排视觉。
- 姓名、风险等级、工号、部门、所属项目继续沿用现有数据字段,不新增业务字段。
- 将风险等级改为细边框状态标识,字段列表改为规整的标签/值双列展示。
- 核心异常标签保留为现有标签数据,仅更新标签外观,不改渲染逻辑。
### 3. 验证情况
- 单测通过:
- `node tests/unit/project-analysis-dialog-layout.test.js`
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
- 浏览器实测:
- 使用 `browser-use` 打开本地真实系统 `http://localhost/`
- 进入项目详情页 `http://localhost/ccdiProject/detail/90337?tab=overview`
- 在“结果总览”页点击“查看详情”,确认“项目分析详情”弹窗已应用正式化外壳样式
- 确认左侧人物档案区样式已按预览稿方向收口,右侧“异常明细”内部业务内容未被重做
- 环境记录:
- `ruoyi-ui/.nvmrc` 期望版本为 `14.21.3`
- 当前终端执行 `nvm use` 失败,原因是 `nvm` 未安装到 PowerShell PATH
- 本次前端校验在当前可用 Node `v22.22.0` 下完成,相关单测通过

View File

@@ -0,0 +1,18 @@
# 2026-05-06 项目分析个人详情页样式对齐实施记录
## 本次修改
- 调整 `ProjectAnalysisDialog.vue`,补齐参考图中的标题区、内容区留白、左右分栏间距和页签样式。
- 调整 `ProjectAnalysisSidebar.vue`,补齐人物档案区、风险等级徽标、命中模型摘要和标签的正式化版式。
- 调整 `ProjectAnalysisAbnormalTab.vue`,补齐流水异常明细表格、异常对象摘要区、快照块和信息行样式。
## 未改内容
- 未改接口请求和数据拼装逻辑。
- 未改页签切换、分页、加入证据库、异常分组和字段内容。
- 未新增或删除业务区块。
## 验证方式
- 使用真实业务页面 `http://localhost/ccdiProject/detail/90337?tab=overview` 打开个人详情弹窗进行样式核对。
- 核对左侧人物档案、右侧页签、表格块和异常对象摘要块的正式化效果。

View File

@@ -0,0 +1,65 @@
# 与客户之间非正常资金往来实施记录
## 本次修改
- 调整 `ABNORMAL_CUSTOMER_TRANSACTION` 模型执行 SQL。
- 调整“涉疑交易明细 -> 名单库命中” SQL。
- 调整涉疑交易明细前端标签展示。
- 补充 SQL 口径测试,不改表结构。
## 模型 SQL 口径
- 位置:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 主体范围:员工本人、`ccdi_staff_fmy_relation.status = 1` 的有效亲属。
- 金额口径:`GREATEST(IFNULL(amount_dr, 0), IFNULL(amount_cr, 0)) > 1000`
- 信贷客户命中:对手方账号命中 `ccdi_account_info.owner_type = 'CREDIT_CUSTOMER'`
- 中介账号命中:对手方账号命中 `ccdi_account_info.owner_type = 'INTERMEDIARY'`
- 中介企业命中:对手方名称精确命中 `ccdi_enterprise_base_info.enterprise_name`,且企业来源为中介。
- 中介人员精确命中:对手方名称精确命中 `ccdi_biz_intermediary.name`
- 中介人员模糊命中:对手方名称包含 `ccdi_biz_intermediary.name`,仅限 `bank in ('ALIPAY', 'WECHAT')`
- 明确不再使用对手方证件号、对手方统一社会信用代码命中。
## 名单库命中口径
- 位置:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- 信贷客户账号命中时,`nameListHitType` 返回“信贷客户”。
- 中介账号、人员名称、企业名称命中时,`nameListHitType` 返回“中介”。
- 名单库命中同样要求交易金额大于 1000。
- 涉疑交易明细和 PDF 导出按 `bank_statement_id` 聚合,避免同一流水因“模型规则命中”和“名单库命中”重复展示。
## 前端展示
- 位置:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ABNORMAL_CUSTOMER_TRANSACTION` 在异常标签中优先展示。
- 如果同一流水名单库类型为“中介”,该标签展示为“疑似与中介往来”。
- 如果同一流水名单库类型为“信贷客户”,该标签展示为“与信贷客户之间非正常资金往来”。
- 如果没有名单库类型,兜底展示为“与客户之间非正常资金往来”。
- 不再把“中介/信贷客户”追加成额外异常标签,避免重复展示。
## 报告展示
- 结果总览 PDF 报告中的涉疑交易明细表同步使用上述标签展示口径。
- 同一条流水只替换 `ABNORMAL_CUSTOMER_TRANSACTION` 的展示文案,不额外追加“中介/信贷客户”标签。
## 影响文件
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectSuspiciousTransactionItemVO.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `docs/reports/implementation/2026-05-21-abnormal-customer-transaction-implementation.md`
## 验证情况
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiProjectOverviewMapperSqlTest" test` 通过21 个测试。
- 测试覆盖的伪造场景口径:
- 信贷客户账号命中。
- 中介账号命中。
- 中介人员名称精确命中。
- 中介人员名称模糊命中仅限 `ALIPAY/WECHAT`
- 名单库命中金额门槛大于 1000。
- 涉疑交易明细按 `bank_statement_id` 聚合去重。
- 涉疑交易明细和 PDF 报告按名单库类型细分展示异常标签,且不重复展示。

View File

@@ -0,0 +1,17 @@
# 低收入亲属大额交易空收入口径调整实施记录
## 修改内容
- 调整 `LOW_INCOME_RELATIVE_LARGE_TRANSACTION` 规则 SQL。
- 关系人 `annual_income is null` 时不再进入低收入候选,不触发该规则。
- 仅当 `annual_income` 明确有值,且满足 `annual_income = 0``annual_income / 12 < 3000` 时,才进入低收入候选。
## 影响范围
- 后端银行流水打标规则:低收入亲属大额交易。
- 不涉及前端展示、接口入参、数据库表结构。
## 验证
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest,CcdiBankTagRuleSqlMetadataTest,BankTagRuleConfigResolverTest" test` 通过,共 43 个测试。

View File

@@ -0,0 +1,63 @@
# dev-ui 本次提交清单
## 提交信息
- 分支:`dev-ui`
- 提交说明:`优化涉疑交易模型口径和报告展示`
## 功能清单
1. 涉疑交易模型口径调整
- `ABNORMAL_CUSTOMER_TRANSACTION` 补充信贷客户账号命中。
- `ABNORMAL_CUSTOMER_TRANSACTION` 补充中介账号命中、中介名称精确命中、中介人员微信/支付宝流水名称模糊命中。
- 名单库命中保留中介和信贷客户两类。
- 金额门槛统一按单边流水金额大于 1000 元判断。
- 低收入亲属大额交易排除年收入为空的亲属;年收入为空不主动预警。
- 大额单笔收入、疑似兼职相关收入预警排除公积金中心收入。
2. 涉疑交易明细展示与导出
- 涉疑交易明细保留“名单库命中”和“模型规则命中”筛选。
- 同一条流水同时命中名单库和模型规则时,按 `bank_statement_id` 聚合去重,不重复展示。
- 名单库命中类型区分为“中介”和“信贷客户”。
- 前端异常标签按名单类型展示:
- 中介:`疑似与中介往来`
- 信贷客户:`与信贷客户之间非正常资金往来`
- 未命中名单类型时:`与客户之间非正常资金往来`
- PDF 导出复用去重后的明细逻辑。
3. 拉取本行流水弹窗
- 证件号码输入提示改为仅支持英文逗号分隔。
- 日期默认开始时间为昨天往前一年,结束时间为昨天。
- 可选日期范围限制为 2025-01-01 到昨天。
4. 报告导出展示
- “一键导出”按钮文案改为“导出报告”。
- PDF 报告章节标题错行问题修正。
- PDF 报告标题加粗展示。
## 涉及文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectSuspiciousTransactionItemVO.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporterTest.java`
## 验证情况
- 已执行后端 Mapper SQL 断言测试。
- 已执行 PDF 导出样式断言测试。
- 已执行前端生产构建。
- 已在本地页面验证涉疑交易明细中“中介/信贷客户”命中展示不重复。
- 已验证 PDF 导出包含“疑似与中介往来”和“与信贷客户之间非正常资金往来”。

View File

@@ -0,0 +1,24 @@
# 拉取本行流水弹窗交互调整实施记录
## 修改内容
- 将证件号码输入提示调整为“仅支持英文逗号分隔”。
- 证件号码解析仅按英文逗号分隔,不再支持中文逗号、顿号、换行等分隔方式。
- 本行存量流水日期默认值调整为:
- 开始日期:昨天往前一年。
- 结束日期:昨天。
- 日期可选范围限制为 `2025-01-01` 到昨天。
## 影响范围
- 前端页面:
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- 不涉及后端接口、不改数据库。
## 验证情况
- 已在真实项目详情页打开“拉取本行流水”弹窗验证:
- 提示文案显示为仅支持英文逗号分隔。
- 默认开始日期为 `2025-05-25`
- 默认结束日期为 `2026-05-25`
- 当前系统日期为 `2026-05-26`,默认日期符合“开始默认一年前、结束默认昨天”。

View File

@@ -0,0 +1,20 @@
# 导出报告章节标题错行修复实施记录
## 修改内容
- 修复结果总览 PDF 导出报告中,表格结束后下一节标题可能压到表格底边框的问题。
- 原因是 PDFBox 绘制文本时 `y` 坐标为文字基线,表格后仅保留 `8F` 间距15 号章节标题的字形会向上占用空间,导致视觉上与上一张表格边框重叠。
- 将表格后间距调整为 `32F`,确保下一节标题与上一张表格有明显稳定留白。
- 章节标题、子标题和封面标题改为加粗展示;正文、表格文字、颜色和列宽不调整。
## 影响范围
- 后端 PDF 报告导出:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java`
- 不涉及前端页面、不改接口、不改数据库。
## 验证情况
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiProjectOverviewReportPdfExporterTest" test` 通过2 个测试。
- 新增测试断言表格后间距必须大于章节标题字号,避免后续回退成过小间距。

View File

@@ -0,0 +1,75 @@
# 专项排查图谱实施记录
## 实施范围
- 当前口径保留资金流图谱和关系图谱两个页签。
- 页面嵌入项目详情“专项排查”页签。
- 后端不再实时聚合 `ccdi_bank_statement`
- 资金流图谱后端读取四张基础图谱表,并叠加手工资金流向表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
- 关系图谱后端读取四张关系图谱表:
- `lx_rel_node`
- `lx_rel_family_edge`
- `lx_rel_stock_edge`
- `lx_rel_represent_edge`
- 默认查询全部流水;用户选择日期时按 `lx_fund_flow_detail_edge.trx_date` 过滤。
- 点击资金边可分页查看逐笔流水。
- 点击可穿透节点可按 `object_key` 设为中心重新查询。
## 关键改动
- 后端 `CcdiFundGraphMapper.xml` 改为四表查询和实时聚合。
- 后端 `CcdiFundGraphServiceImpl` 支持 `objectKey` 查询、节点穿透和边明细查询。
- 后端新增 `/ccdi/project/fund-graph/search` 主体搜索接口。
- 前端新增 `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- 前端新增 `ruoyi-ui/src/api/ccdi/graph/relationGraph.js`
- 前端新增 `FundGraphSection.vue`,提供紧凑型图谱卡片和边明细抽屉。
- `SpecialCheck.vue` 中原“图谱外链展示”占位卡替换为图谱组件。
- `ProjectAnalysisDialog.vue` 中“资金流向”和“关系图谱”页签替换为简版图谱展示。
## 测试数据
测试数据脚本:
```text
sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql
```
dev 库测试数据:
```text
资金流图谱测试身份证号617673198109148314
关系图谱测试身份证号330101198001010011
主体点10
账户点14
持有边14
明细边72
```
覆盖场景:
- 默认全部流水聚合。
- 日期范围筛选聚合。
- 支出 `flag = 1`
- 收入 `flag = 2`
- 家庭关系标签:配偶、父亲、母亲。
- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。
- 家庭关系节点按 `object_key` 设为中心查询。
## 验证
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- `npm run build:prod` 通过。
- dev 库 SQL 校验通过:身份证号可定位主体,默认全部流水和日期筛选均能聚合出资金边。
## 注意
- 生产库 DDL 不随应用发布自动执行。
- 生产需人工执行 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql` 后再发布后端。
- 生产如已建资金流图谱旧表,优先执行 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引。
- `lx_fund_flow_sum_edge` 不作为当前纪检资金图谱页面依赖。
- 如果目标库未建四张 `lx_*` 表,后端接口会报表不存在。

View File

@@ -0,0 +1,26 @@
# 图谱评审问题修复实施记录
## 本次修改
- 修复资金流图谱手工新增边在参数缺失时直接抛出服务端异常的问题,改为返回明确业务错误信息。
- 修复资金流图谱“展开一层”可追溯判断固定按 `to` 侧判断的问题,改为按当前中心节点的对端主体判断是否可继续扩展。
- 调整资金流图谱边列表组装逻辑,将真实边与手工边合并后统一排序,再按同一 `limit` 截断,避免结果条数放大和排序口径不一致。
- 删除未使用的重复前端 API 文件,收敛资金流图谱接口引用入口。
## 影响范围
- 后端资金流图谱接口:
- `POST /ccdi/project/fund-graph/manual-edge`
- `GET /ccdi/project/fund-graph/graph`
- 前端资金流图谱 API 组织方式:
- 保留 `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- 删除未使用的 `ruoyi-ui/src/api/ccdi/fundGraph.js`
## 验证计划
- 后端执行 `mvn -pl ccdi-project -am -DskipTests compile`
- 前端进入 `ruoyi-ui` 后先执行 `nvm use`,再执行 `npm run build:prod`
- 真实页面验收重点:
- 手工新增资金流向缺少必填项时返回明确错误提示
- 资金边“展开一层”按钮在流入/流出两类边下判断更符合对端主体实际可扩展性
- 同一查询条件下真实边与手工边整体排序和数量上限一致

View File

@@ -0,0 +1,18 @@
# 资金流图谱流水查询报错修复实施记录
## 本次修改
- 修复资金流图谱构建节点列表时,对边两端主体二次回查未设置 `limit` 的问题。
- 该问题会使 MyBatis 生成 `LIMIT null`MySQL 报 `near 'null'`,进而导致资金流图谱查询或点击资金边后的流水明细下钻显示失败。
- 保留 Mapper 层默认 `LIMIT 20`,同时在服务层补齐默认上限,避免其他调用路径再次传入空分页上限。
## 影响范围
- 后端接口:`GET /ccdi/project/fund-graph/graph`
- 关联前端表现:资金流图谱加载和资金边流水明细查询
## 验证
- 执行后端编译,确认图谱模块通过编译。
- 重新打包并按项目脚本重启后端,使运行中的 jar 包含本次修复。
- 使用真实接口查询资金流图谱和资金边流水明细,确认不再出现 `LIMIT null` SQL 错误。

View File

@@ -0,0 +1,36 @@
# 图谱改动梳理与验收文档实施记录
## 本次修改
- 新增图谱功能改动梳理与验收清单:`docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- 新增本轮图谱验收执行记录:`docs/tests/records/2026-05-29-graph-acceptance-record.md`
- 新增资金流图谱已建表环境补充 SQL`sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`
- 明确已建表场景下的 SQL 口径:保留 `CREATE TABLE IF NOT EXISTS` 作为新环境参考,已建表只做字段、索引、字符集、排序规则核对和补充。
- 明确当前图谱口径:保留资金流图谱和关系图谱两个页签。
- 明确资金流图谱验收样例:当前可用身份证号为 `617673198109148314`
## 影响范围
- 不修改业务代码或运行配置。
- 新增的 SQL 只用于已建表环境补字段、补索引和补建手工边表,不会删除或清空已有图谱数据。
- 文档覆盖资金流图谱、关系图谱、专项排查页面、项目分析弹窗和数据库补充口径。
## 验证情况
- 后端编译:`mvn -pl ccdi-project -am compile -DskipTests` 通过。
- 前端构建:`npm run build:prod` 通过;`nvm` 在当前 PowerShell 环境不可用,实际 Node 为 `v22.22.0`
- 接口验收:
- 资金图谱查询返回 10 个节点、18 条边、72 笔。
- 资金边明细分页返回正常。
- 关系图谱查询返回 3 个节点、2 条边。
- 疑似同名企业查询返回 1 条候选。
- 页面验收:
- 已打开真实 `http://localhost/` 页面并进入项目 `90342` 专项排查页。
- 资金图谱查询展示 `18 条资金边``72 笔``302,844.78 元`
- 图谱相关网络请求均返回 `200`
- 浏览器控制台未发现 `error`
## 后续事项
- 若生产已建资金流旧表,执行 `06_lx_fund_graph_existing_table_supplement.sql` 前需先人工核对目标库和表结构差异。
- 当前 PowerShell 环境 `nvm` 不可用,前端构建实际使用 Node `v22.22.0`

View File

@@ -0,0 +1,145 @@
# 图谱预备提交改动与功能清单
## 1. 提交范围建议
本清单按“图谱功能”口径整理预备提交内容。提交前建议只纳入下列图谱相关文件,避免混入其他业务、环境配置或本地产物。
### 1.1 后端图谱代码
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiRelationGraphController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFundGraphMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiRelationGraphMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFundGraphService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiRelationGraphService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFundGraphServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiRelationGraphServiceImpl.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFundGraphMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiRelationGraphMapper.xml`
### 1.2 前端图谱代码
- `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- `ruoyi-ui/src/api/ccdi/graph/relationGraph.js`
- `ruoyi-ui/src/views/ccdiProject/components/detail/graph/FundGraphSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisFundFlowTab.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
### 1.3 数据库脚本
- `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`
- `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`
- `sql/ccdi/graph/04_lx_relation_graph_build_mysql.sql`
- `sql/ccdi/graph/05_lx_relation_graph_seed_test_data.sql`
- `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`
说明:
- `01``03` 是新环境建表参考。
- `06` 是资金流图谱已建表环境补字段、补索引脚本,不删除、不重建、不清空基座数据。
- 生产数据库变更由人工确认后手动执行,不随应用发布自动执行。
### 1.4 文档与验收记录
- `docs/plans/backend/2026-05-28-fund-graph-backend-implementation.md`
- `docs/plans/frontend/2026-05-28-fund-graph-frontend-implementation.md`
- `docs/plans/fullstack/2026-05-28-graph-development-decisions.md`
- `docs/plans/fullstack/2026-05-28-graph-production-db-change-list.md`
- `docs/reports/implementation/2026-05-28-fund-graph-special-check-implementation.md`
- `docs/reports/implementation/2026-05-29-fund-graph-review-fixes-implementation.md`
- `docs/reports/implementation/2026-05-29-fund-graph-statement-query-fix.md`
- `docs/reports/implementation/2026-05-29-graph-acceptance-doc-and-verification.md`
- `docs/reports/implementation/2026-05-29-graph-precommit-summary.md`
- `docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- `docs/tests/records/2026-05-29-graph-acceptance-record.md`
### 1.5 提交前需要谨慎确认的文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
这两个文件只增加 `@Lazy` 解决服务循环依赖,属于图谱运行联动修复,可纳入图谱提交。
以下文件当前工作区存在改动,但是否属于图谱提交需单独确认:
- `ruoyi-admin/src/main/resources/application-dev.yml`
- `tongweb_62318.properties`
- `ruoyi-ui/src/views/ccdiProject/components/detail/FamilyAssetLiabilityDetail.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/FamilyAssetLiabilitySection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
以下内容不建议纳入提交:
- `output/` 下的浏览器截图和验收临时产物。
- `docs/prototypes/` 下的视觉探索图片,除非本次明确要提交设计参考图。
- `ruoyi-admin/src/main/resources/!cLEZGP.docx`
- `.DS_Store`
## 2. 功能清单
### 2.1 资金流图谱
- 主体搜索:按身份证号、姓名或 `object_key` 定位资金流主体。
- 一跳图谱:以当前主体为中心查询一层资金往来。
- 实时聚合:基于 `lx_fund_flow_detail_edge` 按当前筛选条件聚合金额、笔数、首末交易时间。
- 日期筛选:按 `trx_date` 做交易日期范围过滤。
- 金额筛选:支持最小汇总金额和金额范围过滤。
- 方向筛选:支持支出 `1`、收入 `2`
- 家庭关系标签:资金边可展示已写入 `lx_fund_flow_detail_edge.family_relation_type` 的配偶、父母、子女等标签;后端不实时按家庭表匹配。
- 边明细下钻:点击真实资金边分页查看逐笔流水。
- 节点详情:点击节点查看主体字段、证件号、账户数、累计金额和笔数。
- 节点穿透:可穿透节点支持“设为中心查询”和“一层展开”。
- 手工资金流向:支持人工录入主体级资金流向边。
- 手工边展示:手工边参与图谱展示和统一排序,但不提供逐笔流水下钻。
- 排序与上限:真实边和手工边合并后统一按金额、笔数、最近交易时间排序并按 `limit` 截断。
- 缺参提示:手工新增缺少起点或终点时返回明确业务提示。
### 2.2 关系图谱
- 主体搜索:按身份证号、姓名、统一社会信用代码或 `object_key` 查主体。
- 一跳关系图谱:以主体为中心展示家庭、股东、法人关系。
- 家庭关系边:展示员工与家庭成员关系。
- 股东持股边:展示自然人股东、企业股东与企业之间的持股关系。
- 法定代表人边:展示法人和企业之间的代表关系。
- 节点详情:展示主体名称、证件号或统一社会信用代码、主体类型、来源类型。
- 边详情:按关系来源展示关系类型、企业名称、持股比例、出资额、家庭关系字段等。
- 疑似同名企业:按人员姓名召回工商法人和自然人股东候选。
- 同名过多阻断:同名候选过多时提示缩小线索范围。
- 年龄过滤:能解析出生日期时过滤企业成立时未满 18 岁的候选。
### 2.3 页面集成
- 专项排查页:原图谱占位卡替换为真实图谱工作台。
- 专项排查资金图谱:展示搜索区、图谱画布、右侧节点/边详情、边明细分页。
- 专项排查关系图谱:展示关系搜索、图谱画布、右侧节点/边详情、疑似企业面板。
- 项目分析弹窗资金流向:展示简版资金图谱,不展示逐笔流水表。
- 项目分析弹窗关系图谱:展示简版关系图谱,切换页签时触发图谱 resize。
- 图谱画布:使用 ECharts 渲染节点、边、方向、标签和关系区分样式。
### 2.4 数据库与部署
- 资金流图谱新环境表结构脚本。
- 资金流图谱测试数据脚本。
- 资金流图谱已建表补充脚本。
- 关系图谱新环境表结构脚本。
- 关系图谱构建脚本。
- 关系图谱测试数据脚本。
- 统一 `utf8mb4``utf8mb4_general_ci` 口径。
- 图谱 DDL 不自动随应用发布执行。
## 3. 最新内容检查
截至 2026-05-29本预备提交清单已按当前代码和验收结果更新
- 当前范围是“资金流图谱 + 关系图谱”。
- 当前资金流图谱验收样例为 `617673198109148314`
- 当前关系图谱验收样例为 `330101198001010011`
- `docs/tests/` 下本轮图谱验收清单和记录已纳入本次提交清单。
- `output/` 仍保持忽略,浏览器截图不纳入提交。

View File

@@ -0,0 +1,25 @@
# 短时间多次存现本人口径调整实施记录
## 修改内容
- 调整后端大额交易模型规则 `FREQUENT_CASH_DEPOSIT`
- 删除规则 SQL 中关系人流水 `UNION ALL` 分支。
- 规则现仅统计员工本人流水,即 `ccdi_base_staff.id_card = ccdi_bank_statement.cret_no`
## 影响范围
- 影响文件:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 影响规则:短时间多次存现。
- 配偶、父母、子女等员工关系人流水不再自动归集到员工本人名下触发该规则。
- 已生成的历史打标结果不会自动变化,需要重新执行项目分析/打标后生效。
## 验证情况
- 已检查 `selectFrequentCashDepositObjects` 规则片段,确认仅保留员工本人分支。
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests`,编译通过。
- 已通过 `output/frequent_cash_deposit_self_only_verification.sql` 写入临时验证数据并执行规则等价 SQL
- 员工 A 本人 3 笔存现,命中员工 A。
- 配偶 B 同时也是员工B 本人 3 笔存现,命中员工 B。
- 当前本人口径不会把 B 的流水归集到员工 A。
- 对照旧关系人分支会额外命中员工 A验证本次删除关系人分支有效。
- 验证脚本末尾已清理临时员工、关系人与流水数据,并复查三张表残留数均为 0。

View File

@@ -0,0 +1,35 @@
# 涉疑交易明细模型命中范围调整实施记录
## 背景
风险明细模块的「涉疑交易」列表此前在模型规则命中部分限制为 `rule_name like '%可疑%'`,并额外包含 `ABNORMAL_CUSTOMER_TRANSACTION`。该口径会导致大额单笔、短时间多笔存现等已有流水级命中模型无法进入涉疑交易明细。
本次确认的业务口径为:涉疑交易明细应放开展示所有流水级模型命中;名单库命中只在同一笔流水同时命中时优先展示匹配对象和标签,不作为模型命中的过滤条件。
## 修改内容
- 调整 `CcdiProjectOverviewMapper.xml``suspiciousTransactionModelHitSql`
- 保留 `tr.project_id = #{query.projectId}`
- 保留 `tr.bank_statement_id is not null`,仅展示可落到具体流水的命中。
- 删除 `rule_name like '%可疑%'``tr.rule_code = 'ABNORMAL_CUSTOMER_TRANSACTION'` 限制。
- 保持名单库命中逻辑不变:
- 信贷客户账号命中优先级为 1。
- 中介账号、姓名、企业名称命中按原 `matchPriority` 聚合。
- 同一笔流水既有模型命中又有名单命中时,`suspiciousPersonName``nameListHitType` 仍优先取名单命中结果。
- 更新 SQL 单测,明确校验模型命中范围已放开,并校验名单命中优先级聚合逻辑仍存在。
## 影响范围
- 影响页面:项目详情风险明细模块的「涉疑交易」列表及导出。
- 影响数据范围:所有 `ccdi_bank_statement_tag_result.bank_statement_id is not null` 的流水级规则命中均可进入涉疑交易明细。
- 不影响对象级命中:`bank_statement_id is null` 的员工/家庭/资产等对象级结果仍不会进入该流水明细列表。
- 不影响名单库命中阈值和匹配规则。
## 验证
- 已执行 `mvn -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest test`,结果通过:`Tests run: 7, Failures: 0, Errors: 0, Skipped: 0`
- 验证点:
- `suspiciousTransactionModelHitSql` 不再包含 `rule_name like '%可疑%'`
- `suspiciousTransactionModelHitSql` 不再包含 `ABNORMAL_CUSTOMER_TRANSACTION` 单规则特例。
- `suspiciousTransactionModelHitSql` 保留 `tr.bank_statement_id is not null`
- 聚合 SQL 保留基于 `matchPriority` 的名单命中优先展示逻辑。

View File

@@ -0,0 +1,38 @@
# 银行流水打标理财类流水剔除实施记录
## 修改背景
- 员工纪检场景下,单纯理财申购、理财购买、结构性存款、理财赎回等流水属于资产形态转换,不应仅因金额较大命中大额交易或炒股模型。
- 桌面本地知识库中的流水底层打标规则将“理财支出/理财收回”作为独立标签口径,银证类逻辑更聚焦“证券/期货 + 银证转账/银转证/证转银”等特征。
## 修改内容
-`CcdiBankTagAnalysisMapper.xml` 新增 `financialProductExclusionPredicate` SQL 片段,按当前流水字段识别理财类流水并排除。
- 大额交易模型接入理财排除:
- 房车消费支出交易
- 税务支出交易
- 大额单笔收入
- 累计收入超限
- 年流水交易额超限
- 大额存现交易
- 短时间多次存现
- 大额转账交易
- 炒股相关模型接入理财排除:
- 可疑银证大额转账
- 大额炒股
- 从“大额炒股”关键词中移除 `理财`,并补充银证转账、银证、证转银、银转证等更贴近炒股转账的关键词。
- 大额转账交易删除 `USER_MEMO NOT LIKE '%款%'` 排除条件。业务口径为“大额转账单笔超过设置限额”,不应因摘要包含“借款、还款、工程款、货款、服务款”等“款”字统一剔除,避免员工纪检场景漏报。
## 影响范围
- 仅影响银行流水打标 SQL 命中口径,不修改前端、接口结构、参数编码和数据库结构。
- 理财类流水将不再因金额较大命中大额交易模型,也不会因包含“理财”关键词命中炒股模型。
- 单独出现“申购/认购/赎回”不会直接被排除,必须同时伴随理财、产品、存款、本金、余额宝、朝朝宝等理财特征,避免误排证券申购或证转银场景。
- 大额转账交易会重新纳入摘要包含“款”的大额转账流水,仍保留同名账户排除与理财类流水排除。
## 验证情况
- XML 解析校验通过:`CcdiBankTagAnalysisMapper.xml` 可正常解析,当前包含 35 个 `select` 与 9 个 `sql` 片段。
- 后端编译验证通过:`mvn -pl ccdi-project -am compile -DskipTests` 执行成功。
- 编译过程中 Maven 提示 `ccdi-info-collection` 存在既有 `ccdi-lsfx` 重复依赖声明警告,本次未修改该模块。
- 本次不启动前端页面测试,修改范围为后端 Mapper SQL。

View File

@@ -0,0 +1,42 @@
# 微信支付宝提现打标规则实施记录
## 修改背景
- 当前 `WITHDRAW_CNT` 使用 `AMOUNT_CR >= 0` 与微信、支付宝关键词识别频繁提现,容易把消费、转账、充值、退款等第三方支付流水误计为提现。
- `WITHDRAW_AMT` 规则元数据和参数已存在,但 Mapper SQL 仍为占位实现,实际不会命中。
- 本次参考桌面本地知识库中支付宝、微信提现识别口径:优先使用 `BANK in ('ALIPAY','WECHAT')` 与提现特征;当 `BANK` 未识别到微信或支付宝时,再使用平台关键词与提现特征组合兜底。
## 修改内容
-`CcdiBankTagAnalysisMapper.xml` 新增 `thirdPartyWithdrawIncomePredicate`,统一识别微信、支付宝提现到账流水。
- 调整 `WITHDRAW_CNT`
- 金额方向改为 `AMOUNT_CR > 0`,表示提现到账收入。
- 不再仅凭微信、支付宝、财付通等平台关键词计数。
- 按员工和交易日统计提现到账次数,超过 `WITHDRAW_CNT` 阈值命中。
- 补齐 `WITHDRAW_AMT`
- 按员工和交易日汇总提现到账收入金额。
- 超过 `WITHDRAW_AMT` 阈值命中。
- Java 调用链补充 `WITHDRAW_AMT` 参数传递:
- Mapper 方法增加 `amountThreshold` 参数。
- Service 执行规则时传入 `WITHDRAW_AMT` 参数。
- 参数解析器增加 `WITHDRAW_AMT -> WITHDRAW_AMT` 映射。
## 性能考虑
- SQL 保留 `project_id``AMOUNT_CR > 0`、员工身份证关联等基础过滤,先缩小参与匹配的数据集。
- `BANK in ('ALIPAY','WECHAT')` 使用精确判断优先命中。
- 只有 `BANK` 未识别为 `ALIPAY/WECHAT` 时,才走支付宝、微信、财付通等关键词兜底,降低误报和无谓匹配范围。
## 影响范围
- 仅影响异常行为模型下:
- `WITHDRAW_CNT` 微信支付宝频繁提现
- `WITHDRAW_AMT` 微信支付宝提现超额
- 不修改前端、数据库结构和菜单权限。
## 验证情况
- XML 解析校验通过:`CcdiBankTagAnalysisMapper.xml` 可正常解析,当前包含 35 个 `select` 与 10 个 `sql` 片段。
- 后端编译验证通过:`mvn -pl ccdi-project -am compile -DskipTests` 执行成功。
- 文本核对通过:`WITHDRAW_CNT` 已不再使用 `AMOUNT_CR >= 0``WITHDRAW_AMT` 已不再使用占位 SQL。
- 编译过程中 Maven 提示 `ccdi-info-collection` 存在既有 `ccdi-lsfx` 重复依赖声明警告,本次未修改该模块。

View File

@@ -0,0 +1,32 @@
# 大额存现与赌博敏感关键词调整实施记录
## 修改时间
2026-06-04
## 修改背景
员工纪检流水打标模型中,大额存现交易需要按员工本人名下流水统计,不再纳入家庭成员名下流水。疑似赌博敏感交易需要覆盖交易摘要或交易对手中的“游戏、抖币、体彩、福彩”等关注字眼,同时删除单字“球”以减少正常球类消费误报。
## 修改内容
1. 调整 `selectLargeCashDepositStatements`
- 保留员工本人证件号匹配 `ccdi_base_staff.id_card = ccdi_bank_statement.cret_no`
- 移除家庭成员证件号匹配 `ccdi_staff_fmy_relation.relation_cert_no = ccdi_bank_statement.cret_no`
2. 调整 `selectGamblingSensitiveKeywordStatements`
- 删除宽泛单字关键词 `球`
- 保留并补充博彩及敏感娱乐关键词:`游戏``抖币``体彩``福彩``彩票``赌博``赌球``下注``投注``球赛投注``外围``博彩``六合``时时彩``赛车``赌场``筹码``盘口``返水``洗码``庄家``闲家``百家乐``斗牛``炸金花``牌九``麻将``捕鱼``电子游艺``VIP666``USDT下注`
- 命中字段仍为交易摘要 `USER_MEMO` 和交易对手 `CUSTOMER_ACCOUNT_NAME`
## 影响范围
- 大额存现交易命中范围收窄为员工本人名下流水。
- 疑似赌博敏感交易继续覆盖游戏、抖币等关注字眼,同时减少单字“球”造成的正常球类消费误报。
- 不影响短时间多次存现规则;该规则此前已经仅统计员工本人名下流水。
- 不新增表结构和参数配置。
## 验证情况
- 已执行 MyBatis XML 解析检查。
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests` 编译验证。

View File

@@ -0,0 +1,30 @@
# 结果总览弹窗资金流向逐笔流水展示实施记录
## 修改内容
- 去除结果总览“查看详情”弹窗资金边详情中的“弹窗速览”提示。
- 结果总览弹窗“资金流向”页签启用逐笔流水表格展示。
- 保持逐笔流水按现有接口分页加载,不一次性拉取全量数据。
- 针对弹窗场景改为上方大图谱、下方逐笔流水明细布局,避免详情栏挤压图谱画布。
- 资金边金额标签改为“万/亿”短格式,选中边时高亮当前边和两端节点,其余关系降透明。
- 单边明细不再展示“返回全量”按钮,改为点击图谱画布空白区域恢复到刚进入“资金流向”页签时的全量图谱第一眼状态。
- 空白点击恢复全量时同步清除选中边、选中节点、逐笔流水数据、明细加载状态和 ECharts 内部强调状态,避免画布残留单条资金边高亮。
- 空白点击监听仅绑定资金流向图谱画布,不影响下方逐笔流水表格和分页操作。
- 逐笔流水接口增加请求序号校验,返回全量后丢弃旧选中边的异步响应,避免旧请求回写导致明细区再次出现。
- 弹窗资金流向明细隐藏累计金额、交易笔数、关系三张汇总卡片,减少重复信息占用空间。
## 影响范围
- 前端页面:结果总览“查看详情”弹窗中的“资金流向”页签。
- 复用组件:`FundGraphSection` 新增弹窗场景可配置项,默认行为不变。
- 不影响专项排查页图谱展示,不修改后端接口和数据库。
## 验证情况
- 前端命令执行前已尝试 `nvm use`,当前环境未识别 `nvm`;实际 Node 版本为 `v22.22.0`npm 版本为 `10.9.4`
- 已多次执行 `npm run build:prod`,构建通过,仅保留现有资源体积提示。
- 已使用真实页面验证 `http://localhost/ccdiProject/detail/90342?tab=overview`:在结果总览点击“查看详情”,切换“资金流向”,选中交易笔数最多的资金边。
- 多笔金额场景验证结果:当前图谱 19 条资金边,选中边为“彭静勇 → 张建强”,交易笔数 5逐笔流水表格显示 5 条并分页显示“共 5 条”。
- 展示验证结果:旧“弹窗速览”提示和“项目分析弹窗仅展示汇总信息”说明均不再出现;金额标签压缩为 `2.22万, 5笔`;汇总卡片已隐藏;逐笔流水展示在图谱下方,未裁切图谱区域。
- 恢复全量验证结果:选中“彭静勇 → 张建强”后展示 5 条逐笔流水,其余 18 条资金边降透明;点击图谱空白区域后,旧明细请求未回写,选中边、选中节点、逐笔流水数据和明细总数均清空;图表仍展示 15 个图形节点、19 条资金边,边样式恢复为 opacity `0.9`、width `1.9`,不再残留单条高亮边。
- 页面截图已保存到 `output/browser-use/project-analysis-fund-flow-final-graph.png``output/browser-use/project-analysis-fund-flow-final-detail.png``output/browser-use/project-analysis-fund-flow-reset-all.png``output/browser-use/project-analysis-fund-flow-reset-first-view.png``output/browser-use/project-analysis-fund-flow-blank-click-reset.png`

View File

@@ -0,0 +1,337 @@
# 关联业务自动补入实体库设计
## 1. 背景
当前信息维护中多条业务链路都会录入或导入统一社会信用代码和企业名称,但实体库 `ccdi_enterprise_base_info` 不一定已经存在对应企业。现状中部分链路只保存业务关联表,部分链路会因为实体库不存在而失败,导致“关联业务已经知道企业信息,但实体库没有沉淀”的数据断点。
本次需求要求以下模块在新建、导入时,如果要关联的实体在系统实体库不存在,则自动将不存在的实体添加到实体库中:
- 员工亲属实体关联
- 中介库与实体关联
- 信贷客户实体关联
- 招投标信息维护
本次设计按最短路径实现,不增加兼容性分支,不扩展用户未提出的兜底或降级逻辑。
## 2. 目标与范围
### 2.1 目标
在保留现有页面交互和业务校验规则的前提下,为上述四类业务的新建和导入链路补齐实体库自动沉淀能力,保证业务数据成功落库时,对应实体也已存在于实体库。
### 2.2 本次范围
- 新增后端内部实体库自动补全能力
- 改造员工亲属实体关联新建、导入链路
- 改造中介实体关联新建、导入链路
- 改造信贷客户实体关联新建、导入链路
- 改造招投标信息维护新建、导入链路中的供应商实体补入
- 新增企业来源 `SUPPLIER=供应商`
- 保证中介库管理新增、导入实体时风险等级默认为高风险
- 补充对应后端、前端与测试文档
### 2.3 非本次范围
- 不修改实体库主键规则,仍以统一社会信用代码作为实体唯一标识
- 不在实体库已存在时覆盖企业名称、来源、风险等级、数据来源等字段
- 不从外部接口拉取企业工商详情
- 不为没有统一社会信用代码的招投标供应商创建实体库记录
- 不新增用户确认弹窗或前端交互分支
- 不修改关联表与招投标主从表结构
## 3. 现状分析
### 3.1 实体库
实体库表为 `ccdi_enterprise_base_info`,主键为 `social_credit_code`。核心字段包括:
- `social_credit_code`
- `enterprise_name`
- `risk_level`
- `ent_source`
- `data_source`
- `created_by`
- `updated_by`
当前企业来源枚举包含:
- `GENERAL`:一般企业
- `EMP_RELATION`:员工关系人
- `CREDIT_CUSTOMER`:信贷客户
- `INTERMEDIARY`:中介
- `BOTH`:兼有
当前缺少供应商来源,需要新增 `SUPPLIER`
### 3.2 各业务链路
员工亲属实体关联:
- 新建、导入会校验有效员工亲属
- 业务关联表保存统一社会信用代码和企业名称
- 当前不会补入实体库
中介实体关联:
- 新建时要求关联机构必须已存在
- 导入时当前会因“统一社会信用代码不存在于系统机构表”失败
- 本次需要改为实体库缺失时自动补入
信贷客户实体关联:
- 新建、导入保存统一社会信用代码和企业名称
- 当前不会补入实体库
招投标信息维护:
- 新建、导入维护供应商明细
- 供应商明细包含供应商名称和统一信用代码
- 当前不会补入实体库
## 4. 实现方案
### 4.1 新增后端内部实体库自动补全服务
本次采用单一路径实现:
- 新增一个内部复用能力,统一接收统一社会信用代码、企业名称、企业来源、数据来源和操作人
- 各业务 Service 在成功落业务数据前调用
- 已存在则不处理,不存在则插入最小实体记录
采用该方案的原因:
- 规则集中,避免四处重复
- 新建与导入可复用同一口径
- 后续新增业务来源时扩展点明确
- 需要各业务链路接入该内部服务
- 满足最短路径实现
- 业务规则集中可测
- 不改变前端交互
- 可以明确保证已存在实体不被覆盖
## 5. 数据规则
### 5.1 实体识别规则
自动补入实体库时,只按统一社会信用代码判断实体是否存在。
-`ccdi_enterprise_base_info.social_credit_code` 已存在:不更新实体库任何字段
- 若不存在:新增一条最小实体记录
### 5.2 最小实体记录字段
自动补入时写入以下字段:
| 字段 | 规则 |
|------|------|
| `social_credit_code` | 来源业务中的统一社会信用代码 |
| `enterprise_name` | 来源业务中的企业名称或供应商名称 |
| `ent_source` | 按业务来源映射 |
| `risk_level` | 仅中介来源默认为 `1`,其他来源按 `NULL` 落库 |
| `data_source` | 新建为 `MANUAL`,导入为 `IMPORT` |
| `created_by` | 当前操作人 |
| `updated_by` | 当前操作人 |
其他实体字段保持为空。自动补入能力必须使用独立插入路径,不复用实体库管理手工新增中要求风险等级必填的校验逻辑;员工亲属、信贷客户、供应商三类自动补入记录必须显式保证 `risk_level``NULL` 落库,不能吃到历史表默认低风险值。
### 5.3 企业来源映射
| 触发业务 | `ent_source` | `risk_level` |
|----------|--------------|--------------|
| 员工亲属实体关联 | `EMP_RELATION` | 空 |
| 中介实体关联 | `INTERMEDIARY` | `1` |
| 中介库管理新增实体 | `INTERMEDIARY` | `1` |
| 中介库管理导入实体 | `INTERMEDIARY` | `1` |
| 信贷客户实体关联 | `CREDIT_CUSTOMER` | 空 |
| 招投标供应商 | `SUPPLIER` | 空 |
需要新增企业来源枚举:
- `SUPPLIER("SUPPLIER", "供应商")`
企业来源选项由现有 `EnterpriseSource` 枚举接口下发,实体库管理页面应通过该接口正常显示和筛选“供应商”。
### 5.4 同批重复规则
导入场景中,如果同一批成功数据内同一个统一社会信用代码出现多次,且实体库当前不存在:
- 只补入一次实体库
- 使用首次有效出现的企业名称或供应商名称
- 后续相同统一社会信用代码不覆盖已补入实体
## 6. 业务链路设计
### 6.1 员工亲属实体关联
新建:
1. 校验亲属身份证号必须是有效员工亲属
2. 校验亲属身份证号和统一社会信用代码组合唯一
3. 调用实体库自动补全,来源为 `EMP_RELATION`,数据来源为 `MANUAL`
4. 插入员工亲属实体关联
导入:
1. 保持现有每行基础校验、有效亲属校验、重复组合校验
2. 仅对校验成功行收集实体信息
3. 批量自动补入缺失实体,来源为 `EMP_RELATION`,数据来源为 `IMPORT`
4. 批量插入员工亲属实体关联成功行
5. 校验失败行不补入实体库
### 6.2 中介实体关联
新建:
1. 校验中介本人存在
2. 不再将“关联机构不存在”作为失败条件
3. 校验中介和统一社会信用代码组合唯一
4. 调用实体库自动补全,来源为 `INTERMEDIARY`,风险等级为 `1`,数据来源为 `MANUAL`
5. 插入中介实体关联
导入:
1. 保持中介本人存在、字段格式、重复组合等校验
2. 取消“统一社会信用代码不存在于系统机构表”失败条件
3. 对校验成功行收集实体信息
4. 批量自动补入缺失实体,来源为 `INTERMEDIARY`,风险等级为 `1`,数据来源为 `IMPORT`
5. 批量插入中介实体关联成功行
中介库管理新增、导入实体:
- 新增实体时继续直接写实体库,风险等级默认为 `1`
- 导入实体时成功记录的风险等级默认为 `1`
### 6.3 信贷客户实体关联
新建:
1. 校验身份证号和统一社会信用代码组合唯一
2. 调用实体库自动补全,来源为 `CREDIT_CUSTOMER`,数据来源为 `MANUAL`
3. 插入信贷客户实体关联
导入:
1. 保持现有字段格式、重复组合等校验
2. 对校验成功行收集实体信息
3. 批量自动补入缺失实体,来源为 `CREDIT_CUSTOMER`,数据来源为 `IMPORT`
4. 批量插入信贷客户实体关联成功行
### 6.4 招投标信息维护
新建:
1. 保持采购事项 ID 唯一性校验
2. 保持供应商唯一中标、重复供应商等校验
3. 从供应商明细中收集 `supplierUscc` 不为空的供应商
4. 调用实体库自动补全,来源为 `SUPPLIER`,数据来源为 `MANUAL`
5. 插入招投标主信息和供应商明细
导入:
1. 保持双 Sheet 主从关系、主信息字段、供应商字段、重复供应商等校验
2. 仅对校验成功的采购事项收集供应商实体信息
3. 只处理供应商统一信用代码不为空的供应商
4. 批量自动补入缺失实体,来源为 `SUPPLIER`,数据来源为 `IMPORT`
5. 批量插入招投标主信息和供应商明细
没有统一信用代码的供应商不补实体库,也不因此失败。
## 7. 事务与并发
### 7.1 新建事务
新建场景中,实体库补全和业务数据插入在同一事务内执行。任一环节失败,本次新建整体回滚。
### 7.2 导入事务
导入场景保持现有异步任务机制。每个导入任务在成功数据批量落库前先执行实体库补全,再写业务表。若实体补全失败,对应成功候选数据不得静默写入业务表。
### 7.3 并发重复
如果两个请求同时补入同一统一社会信用代码:
- 查询时不存在但插入时遇到主键重复,应按“实体已存在”处理
- 不覆盖并发方已写入的实体字段
- 不影响当前业务数据继续落库
## 8. 异常处理
保留现有业务校验错误:
- 字段必填失败
- 统一社会信用代码格式错误
- 身份证号格式错误
- 亲属不存在或无效
- 中介本人不存在
- 重复关联组合
- 重复供应商
- 招投标主从 Sheet 关系异常
不再作为失败原因:
- 中介实体关联中的“统一社会信用代码不存在于系统机构表”
自动补入实体库时,企业名称为空或超过长度不单独新增兜底规则,沿用各业务现有字段校验结果。
## 9. 前端与枚举
前端不新增交互。
需要同步新增或调整:
- 后端 `EnterpriseSource` 枚举新增 `SUPPLIER("SUPPLIER", "供应商")`
- 现有企业来源枚举接口应返回供应商选项
- 实体库管理页面企业来源下拉、列表、详情中的枚举展示应能正确显示“供应商”
## 10. 测试设计
### 10.1 后端测试
需要覆盖:
- 员工亲属实体关联新建:实体库无记录时补入 `EMP_RELATION`,风险为空
- 员工亲属实体关联导入:成功行补入实体库,失败行不补
- 中介实体关联新建:实体库无记录时补入 `INTERMEDIARY`,风险为 `1`
- 中介实体关联导入:原实体不存在场景由失败改为成功
- 中介库管理新增实体:未填写风险等级时写入 `riskLevel=1`
- 中介库管理导入实体:成功记录写入 `riskLevel=1`
- 信贷客户实体关联新建:实体库无记录时补入 `CREDIT_CUSTOMER`,风险为空
- 信贷客户实体关联导入:成功行补入实体库,失败行不补
- 招投标新建:供应商统一信用代码存在时补入 `SUPPLIER`,风险为空
- 招投标导入:成功采购事项的供应商补入实体库,失败采购事项不补
- 已存在实体不覆盖原有名称、来源、风险等级、数据来源
- 同批重复统一社会信用代码只补一次,首次名称生效
- 并发或插入重复时按已存在处理
- 员工亲属、信贷客户、招投标供应商自动补入记录的 `risk_level``NULL`
### 10.2 前端测试
需要覆盖:
- 实体库管理企业来源筛选项包含“供应商”
- 实体库列表和详情能够显示供应商来源
- 招投标页面新建供应商后,实体库可查询到自动补入的供应商实体
完成页面功能开发后,需要使用 Playwright 打开真实业务页面验证,不使用原型页面。
### 10.3 导入测试
导入测试必须进入真实业务页面执行:
- 下载当前页面模板
- 基于模板生成测试文件
- 覆盖成功导入、失败行不补实体、混合成功失败、同批重复统一社会信用代码等场景
- 测试结束后清理本轮成功写入的测试数据和导入任务缓存
## 11. 实施文档要求
本次功能涉及后端服务、企业来源枚举和前端枚举展示,因此后续实施计划需要拆分:
- 后端实施计划:`docs/plans/backend/`
- 前端实施计划:`docs/plans/frontend/`
实现完成后需要新增实施记录:
- `docs/reports/implementation/`
实施记录需包含修改内容、影响范围和验证情况。

View File

@@ -0,0 +1,127 @@
# 图谱功能改动梳理与验收清单
## 1. 验收目标
本清单用于继续验收当前图谱相关改动,覆盖资金流图谱和关系图谱两部分,重点确认:
- 已改动内容分别承担什么功能。
- 后端接口是否返回正常,是否出现 `500`、鉴权异常或 SQL 报错。
- 专项排查和项目分析弹窗中的真实页面是否能打开和查询。
- 已建图谱表不重复建表,仅核对必要字段、索引和补充脚本。
## 2. 改动梳理
### 2.1 资金流图谱
| 类型 | 文件/入口 | 功能 |
| --- | --- | --- |
| 后端 Controller | `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java` | 提供主体搜索、一层资金图谱查询、边流水明细分页、手工资金流向保存接口。 |
| 后端 Service | `CcdiFundGraphServiceImpl.java` | 参数归一化、中心主体定位、真实边和手工边合并排序、节点组装、手工边保存、默认 `limit` 防空。 |
| 后端 Mapper | `CcdiFundGraphMapper.java``CcdiFundGraphMapper.xml` | 读取 `lx_fund_flow_*` 表,按账户明细实时聚合主体资金边,查询逐笔流水,写入手工边。 |
| DTO/VO | `CcdiFundGraph*DTO.java``CcdiFundGraph*VO.java` | 定义查询条件、手工边保存参数、图谱节点、资金边、流水明细返回结构。 |
| 前端 API | `ruoyi-ui/src/api/ccdi/graph/fundGraph.js` | 封装 `/ccdi/project/fund-graph/*` 接口。 |
| 前端组件 | `FundGraphSection.vue` | 资金图谱主工作台搜索、日期范围、最小金额、ECharts 图谱、节点/边详情、边流水分页、手工新增资金流向。 |
| 专项排查入口 | `SpecialCheck.vue` | 将原图谱占位卡替换为真实图谱组件。 |
| 项目分析弹窗 | `ProjectAnalysisDialog.vue``ProjectAnalysisFundFlowTab.vue` | 在“资金流向”页签内展示简版资金图谱,弹窗内不展示逐笔流水表。 |
| SQL | `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql``02_lx_fund_graph_seed_test_data.sql``06_lx_fund_graph_existing_table_supplement.sql` | 记录资金图谱必要表结构、开发联调测试数据,以及已建表环境补字段/补索引脚本。 |
### 2.2 关系图谱
| 类型 | 文件/入口 | 功能 |
| --- | --- | --- |
| 后端 Controller | `CcdiRelationGraphController.java` | 提供主体搜索、一层关系图谱查询、疑似同名企业查询接口。 |
| 后端 Service | `CcdiRelationGraphServiceImpl.java` | 关系图谱主体定位、家庭/股东/法人边合并展示、疑似企业按姓名召回与年龄规则过滤。 |
| 后端 Mapper | `CcdiRelationGraphMapper.java``CcdiRelationGraphMapper.xml` | 读取 `lx_rel_node``lx_rel_family_edge``lx_rel_stock_edge``lx_rel_represent_edge`。 |
| DTO/VO | `CcdiRelationGraph*DTO.java``CcdiRelationGraph*VO.java` | 定义关系图谱查询、节点、边、疑似企业候选返回结构。 |
| 前端 API | `ruoyi-ui/src/api/ccdi/graph/relationGraph.js` | 封装 `/ccdi/project/relation-graph/*` 接口。 |
| 前端组件 | `FundGraphSection.vue` | 同一组件内提供“关系图谱”页签、节点/边详情和疑似同名企业面板。 |
| 项目分析弹窗 | `ProjectAnalysisDialog.vue` | “关系图谱”页签由占位页改为真实关系图谱简版展示。 |
| SQL | `03_lx_relation_graph_mysql_ddl.sql``04_lx_relation_graph_build_mysql.sql``05_lx_relation_graph_seed_test_data.sql` | 关系图谱建表、构建和测试数据脚本。DDL 已包含旧表补字段/补索引逻辑。 |
### 2.3 关联修复
| 文件 | 功能 |
| --- | --- |
| `CcdiModelParamServiceImpl.java` | 对 `ICcdiBankTagService` 增加 `@Lazy`,缓解服务循环依赖启动问题。 |
| `CcdiProjectOverviewServiceImpl.java` | 对 `ICcdiModelParamService` 增加 `@Lazy`,缓解服务循环依赖启动问题。 |
| `application-dev.yml``tongweb_62318.properties` | 本地/部署运行配置存在改动,不纳入本次图谱验收清单。 |
## 3. 建表与补充脚本验收口径
当前表已建好,本轮不重复执行建表脚本。验收时按以下口径处理:
- `CREATE TABLE IF NOT EXISTS` 可保留在仓库内,作为新环境初始化和评审依据。
- 已存在表只做字段、索引、字符集、排序规则核对。
- 如已建资金流图谱表缺字段或缺索引,优先使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补充,不要先删表再重建。
- 如已建关系图谱表缺字段或缺索引,优先使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑。
- 所有图谱表、字符字段和新增索引保持 `utf8mb4``utf8mb4_general_ci` 口径。
- 执行包含中文内容的 SQL 文件时使用 `bin/mysql_utf8_exec.sh <sql-file>`
- 生产环境 DDL 和补充 SQL 由人工确认后手动执行,不随应用发布自动执行。
资金流图谱需核对:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
关系图谱需核对:
- `lx_rel_node`
- `lx_rel_family_edge`
- `lx_rel_stock_edge`
- `lx_rel_represent_edge`
## 4. 验收清单
### 4.1 静态与构建
- [ ] 后端 `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- [ ] 前端执行前确认 Node 版本;如 `nvm` 不可用,记录实际 Node 版本。
- [ ] 前端 `npm run build:prod` 通过,无新增编译错误。
- [ ] 检查暂存区确认验收文档、业务代码、SQL、构建产物边界清晰。
### 4.2 资金流图谱接口
- [ ] 未登录访问图谱接口返回认证失败,不泄露数据。
- [ ] 管理员登录后,`/ccdi/project/fund-graph/search` 可按身份证号查到主体。
- [ ] `/ccdi/project/fund-graph/graph` 默认查询返回节点、边、总金额、交易笔数。
- [ ] 日期范围查询返回正常,不出现 SQL 字符集/排序规则错误。
- [ ] `direction=1` 支出查询返回正常。
- [ ] `direction=2` 收入查询返回正常。
- [ ] 点击边对应的 `/edge-detail` 分页返回正常,不再出现 `LIMIT null`
- [ ] 手工新增资金流向缺少起点时返回明确业务错误,不抛服务端异常栈。
- [ ] 手工边与真实边合并后统一排序和统一 `limit`
### 4.3 关系图谱接口
- [ ] `/ccdi/project/relation-graph/search` 可按身份证号或姓名查到主体。
- [ ] `/ccdi/project/relation-graph/graph` 返回家庭、股东、法人关系边。
- [ ] `/ccdi/project/relation-graph/suspected-enterprises` 可按姓名召回疑似企业。
- [ ] 同名候选过多时返回阻断提示,不直接大批量展示。
- [ ] 出生日期或身份证可用时,成立时未满 18 岁候选被过滤。
### 4.4 专项排查页面
- [ ] 登录后进入真实项目详情页,不打开原型页。
- [ ] 切换到“专项排查”,图谱分析区域出现。
- [ ] 资金流图谱默认空态文案正确。
- [ ] 输入身份证号后点击查询,资金边统计展示正确。
- [ ] 图谱画布非空白,页面无明显遮挡、错位。
- [ ] 点击资金边后右侧展示金额、笔数、关系标签和逐笔流水。
- [ ] 切换到关系图谱页签,搜索后页面无白屏、无控制台错误。
- [ ] 浏览器控制台无 `error`,图谱相关网络请求均为 `200`
### 4.5 项目分析弹窗
- [ ] 在结果总览人员行点击“查看详情”打开项目分析弹窗。
- [ ] “资金流向”页签展示简版图谱,无搜索栏,画布能渲染。
- [ ] “关系图谱”页签展示简版关系图谱,切换页签后图谱重新 resize。
- [ ] 弹窗版不展示逐笔流水表,仅展示汇总信息。
- [ ] 弹窗控制台无图谱 resize、ECharts 初始化、接口调用异常。
### 4.6 待重点复核项
- [ ] `nvm` 在当前 PowerShell 环境不可用;前端构建已记录实际 Node 版本。
- [ ] 已建表环境执行补充前,先人工确认目标库、表结构差异和备份策略。

View File

@@ -0,0 +1,101 @@
# 2026-05-29 图谱功能验收执行记录
## 1. 执行信息
- 执行时间2026-05-29 17:36 ~ 17:43Asia/Shanghai
- 验收环境:本机真实服务
- 前端地址:`http://localhost/`
- 后端地址:`http://localhost:62318`
- 验收清单:`docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- 测试项目:`projectId=90342`,项目名 `test拉取行内流水`
- 管理员账号:`admin`
## 2. 构建与静态验证
| 项目 | 命令 | 结果 |
| --- | --- | --- |
| 后端编译 | `mvn -pl ccdi-project -am compile -DskipTests` | 通过 |
| 前端 Node 确认 | `nvm use; node -v` | `nvm` 不可用,实际 Node 为 `v22.22.0` |
| 前端构建 | `npm run build:prod` | 通过,有既有资源体积 warning |
说明:
- Maven 输出存在既有重复依赖声明 warning`ccdi-info-collection``ccdi-lsfx` 依赖重复。
- 前端构建只有资源体积 warning无编译失败。
## 3. 接口验收
### 3.1 鉴权
- 未登录访问 `/ccdi/project/fund-graph/search`:返回 `401`,符合预期。
- 未登录访问 `/ccdi/project/relation-graph/search`:返回 `401`,符合预期。
- 使用 `/login` 登录成功,后续接口带 `Bearer token` 验收。
### 3.2 资金流图谱
测试身份证号:`617673198109148314`
| 接口/场景 | 结果 |
| --- | --- |
| `/ccdi/project/fund-graph/search?keyword=617673198109148314` | `code=200`,查到 1 个主体 |
| `/ccdi/project/fund-graph/graph?keyword=617673198109148314&limit=20&minTotalAmount=0` | `code=200`10 个节点、18 条边、72 笔、总金额 `302844.78` |
| 日期范围查询 | `code=200`18 条边、72 笔 |
| `direction=1` 支出查询 | `code=200`9 条边、45 笔 |
| `direction=2` 收入查询 | `code=200`9 条边、27 笔 |
| 第一条资金边明细分页 | `code=200`,返回 5 条,总数 5 |
| 手工新增资金流向缺少起点 | 返回 `code=500`,业务提示:`起点主体不能为空` |
结论:
- 资金图谱核心查询、方向筛选、边明细分页可用。
- 手工新增缺少必填项时已返回明确提示,未出现服务端异常栈。
### 3.3 关系图谱
测试身份证号:`330101198001010011`
| 接口/场景 | 结果 |
| --- | --- |
| `/ccdi/project/relation-graph/search?keyword=330101198001010011` | `code=200`,查到 1 个主体 |
| `/ccdi/project/relation-graph/graph?keyword=330101198001010011&limit=80` | `code=200`3 个节点、2 条边 |
| `/ccdi/project/relation-graph/suspected-enterprises` | `code=200`,返回 1 条疑似企业,`blocked=false` |
结论:
- 关系图谱接口可用,疑似企业查询可用。
## 4. 页面验收
使用 Playwright CLI 打开真实页面 `http://localhost/` 执行:
1. 登录系统。
2. 进入初核项目管理。
3. 打开项目 `90342` 的详情页。
4. 切换到“专项排查”。
5. 在“资金流图谱”输入 `617673198109148314` 并查询。
6. 切换“关系图谱”,输入 `330101198001010011` 并查询。
7. 检查控制台和网络请求。
结果:
- 专项排查页面可正常加载图谱分析区域。
- 资金流图谱查询后页面展示 `18 条资金边``72 笔``302,844.78 元`
- 关系图谱查询接口返回 `200`,页面无白屏。
- 图谱相关网络请求:
- `/dev-api/ccdi/project/fund-graph/graph?keyword=617673198109148314&minTotalAmount=1000&limit=20` 返回 `200`
- `/dev-api/ccdi/project/relation-graph/graph?keyword=330101198001010011&limit=80` 返回 `200`
- 浏览器控制台 `error` 数量为 0。
- 截图证据:`output/playwright/graph-acceptance-special-check.png`
## 5. 注意事项
| 编号 | 级别 | 事项 | 影响 | 建议 |
| --- | --- | --- | --- | --- |
| GRAPH-002 | P2 | 当前 PowerShell 环境 `nvm` 不可用 | 不满足“前端命令前先 nvm use”的执行规范 | 修复 nvm 安装或 PATH本轮实际使用 Node `v22.22.0` |
| GRAPH-004 | P2 | 资金流 DDL 原先只有新建表口径,已建旧表需要差异补充 | 生产若已建旧表但缺字段,单靠 `CREATE TABLE IF NOT EXISTS` 不会补齐 | 已补充 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`,执行前需人工核对 |
## 6. 阶段性结论
本轮未发现图谱接口 `500`、页面白屏或浏览器控制台 error。资金流图谱和关系图谱在当前本机真实服务上均可查询。
当前图谱主链路验收通过。剩余事项为环境类或发布前核对类事项:`GRAPH-002` 需要修复本机 `nvm` 环境,`GRAPH-004` 需要在生产执行补充 SQL 前人工核对目标库和表结构差异。

View File

@@ -28,6 +28,7 @@
<oshi.version>6.9.1</oshi.version> <oshi.version>6.9.1</oshi.version>
<commons.io.version>2.21.0</commons.io.version> <commons.io.version>2.21.0</commons.io.version>
<poi.version>4.1.2</poi.version> <poi.version>4.1.2</poi.version>
<pdfbox.version>2.0.30</pdfbox.version>
<easyexcel.version>3.3.4</easyexcel.version> <easyexcel.version>3.3.4</easyexcel.version>
<velocity.version>2.3</velocity.version> <velocity.version>2.3</velocity.version>
<jwt.version>0.9.1</jwt.version> <jwt.version>0.9.1</jwt.version>
@@ -131,6 +132,13 @@
<version>${poi.version}</version> <version>${poi.version}</version>
</dependency> </dependency>
<!-- pdf导出工具 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<!-- easyexcel工具 --> <!-- easyexcel工具 -->
<dependency> <dependency>
<groupId>com.alibaba</groupId> <groupId>com.alibaba</groupId>

View File

@@ -0,0 +1,146 @@
# 开发环境配置
ruoyi:
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
profile: backend/uploadPath
# 开发环境配置
server:
# 服务器的HTTP端口默认为8080
port: 62318
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数默认为100
accept-count: 1000
threads:
# tomcat最大线程数默认为200
max: 800
# Tomcat启动初始化的线程数默认值10
min-spare: 100
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
url: jdbc:mysql://158.234.199.250:3306/ccdi?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: dbicm
password: Kfcx@1234
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ruoyi
login-password: 123456
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
config:
multi-statement-allow: true
data:
# redis 配置
redis:
# 地址
host: r-kz640f6b20dac724.redis.rds.ops.dc-tst-zj96596.com
# 端口默认为6379
port: 6379
# 数据库索引
database: 9
# 密码
password: Kfcx@1234
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池中的最小空闲连接
min-idle: 0
# 连接池中的最大空闲连接
max-idle: 8
# 连接池的最大数据库连接数
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 流水分析平台配置
lsfx:
api:
base-url: http://158.234.196.5:82/c4c3
# 生产环境
# base-url: http://64.202.32.176/c4c3
# 认证配置
app-id: remote_app
app-secret: dXj6eHRmPv # 见知提供的密钥
client-id: c2017e8d105c435a96f86373635b6a09 # 测试环境固定值
# 接口路径配置
endpoints:
get-token: /account/common/getToken
upload-file: /watson/api/project/remoteUploadSplitFile
fetch-inner-flow: /watson/api/project/getJZFileOrZjrcuFile
check-parse-status: /watson/api/project/upload/getpendings
get-bank-statement: /watson/api/project/getBSByLogId
# 新增接口
get-file-upload-status: /watson/api/project/bs/upload
delete-files: /watson/api/project/batchDeleteUploadFile
# RestTemplate配置
connection-timeout: 30000 # 连接超时30秒
read-timeout: 60000 # 读取超时60秒
# 连接池配置
pool:
max-total: 100 # 最大连接数
default-max-per-route: 20 # 每个路由最大连接数
credit-parse:
api:
url: http://192.168.0.111:62320/xfeature-mngs/conversation/htmlEval

View File

@@ -0,0 +1,146 @@
# 开发环境配置
ruoyi:
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
profile: backend/uploadPath
# 开发环境配置
server:
# 服务器的HTTP端口默认为8080
port: 62318
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数默认为100
accept-count: 1000
threads:
# tomcat最大线程数默认为200
max: 800
# Tomcat启动初始化的线程数默认值10
min-spare: 100
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
url: jdbc:mysql://158.234.199.250:3306/ccdi?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: dbicm
password: Kfcx@1234
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ruoyi
login-password: 123456
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
config:
multi-statement-allow: true
data:
# redis 配置
redis:
# 地址
host: r-kz640f6b20dac724.redis.rds.ops.dc-tst-zj96596.com
# 端口默认为6379
port: 6379
# 数据库索引
database: 9
# 密码
password: Kfcx@1234
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池中的最小空闲连接
min-idle: 0
# 连接池中的最大空闲连接
max-idle: 8
# 连接池的最大数据库连接数
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 流水分析平台配置
lsfx:
api:
base-url: http://158.234.196.5:82/c4c3
# 生产环境
# base-url: http://64.202.32.176/c4c3
# 认证配置
app-id: remote_app
app-secret: dXj6eHRmPv # 见知提供的密钥
client-id: c2017e8d105c435a96f86373635b6a09 # 测试环境固定值
# 接口路径配置
endpoints:
get-token: /account/common/getToken
upload-file: /watson/api/project/remoteUploadSplitFile
fetch-inner-flow: /watson/api/project/getJZFileOrZjrcuFile
check-parse-status: /watson/api/project/upload/getpendings
get-bank-statement: /watson/api/project/getBSByLogId
# 新增接口
get-file-upload-status: /watson/api/project/bs/upload
delete-files: /watson/api/project/batchDeleteUploadFile
# RestTemplate配置
connection-timeout: 30000 # 连接超时30秒
read-timeout: 60000 # 读取超时60秒
# 连接池配置
pool:
max-total: 100 # 最大连接数
default-max-per-route: 20 # 每个路由最大连接数
credit-parse:
api:
url: http://192.168.0.111:62320/xfeature-mngs/conversation/htmlEval

View File

@@ -0,0 +1,33 @@
import request from "@/utils/request";
export function searchFundGraphSubjects(query) {
return request({
url: "/ccdi/project/fund-graph/search",
method: "get",
params: query,
});
}
export function getFundGraph(query) {
return request({
url: "/ccdi/project/fund-graph/graph",
method: "get",
params: query,
});
}
export function getFundGraphEdgeDetail(query) {
return request({
url: "/ccdi/project/fund-graph/edge-detail",
method: "get",
params: query,
});
}
export function saveFundGraphManualEdge(data) {
return request({
url: "/ccdi/project/fund-graph/manual-edge",
method: "post",
data,
});
}

View File

@@ -0,0 +1,25 @@
import request from "@/utils/request";
export function searchRelationGraphSubjects(query) {
return request({
url: "/ccdi/project/relation-graph/search",
method: "get",
params: query,
});
}
export function getRelationGraph(query) {
return request({
url: "/ccdi/project/relation-graph/graph",
method: "get",
params: query,
});
}
export function getRelationGraphSuspectedEnterprises(query) {
return request({
url: "/ccdi/project/relation-graph/suspected-enterprises",
method: "get",
params: query,
});
}

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