Compare commits

26 Commits

Author SHA1 Message Date
wkc
ce66dc3ba8 项目上传删除暂不调用流水分析平台 2026-06-26 15:19:23 +08:00
wkc
f3c1e2ea93 移除风险模型导出按钮 2026-06-26 15:18:46 +08:00
wkc
87b2352001 为风险明细列表添加分页选择器 2026-06-26 15:16:30 +08:00
wjj
999350265b 优化项目详情资金流向图谱展示 2026-06-26 10:05:46 +08:00
wjj
f8ee1ecf1c 调整流水模型理财剔除与提现规则 2026-06-26 10:05:46 +08:00
wjj
35467fd361 放开涉疑交易流水级模型命中 2026-06-26 10:05:46 +08:00
wjj
64cb847db3 优化涉疑交易模型口径和报告展示 2026-06-26 10:03:33 +08:00
wjj
bf290c509c 补充项目分析个人详情页正式化样式 2026-06-26 10:00:09 +08:00
wkc
c5b2033a3d 优化资金图谱主题节点检索 2026-06-03 17:11:09 +08:00
wkc
d45e9410ef 实现结果总览详情资产和征信页签 2026-06-02 17:17:49 +08:00
wkc
457e6c1d27 调整项目人数按流水证件号统计 2026-06-02 10:38:43 +08:00
wkc
850f97ea22 调整专项核查图谱展示 2026-06-01 17:37:59 +08:00
wjj
de6e6bd628 放开涉疑交易流水级模型命中 2026-06-01 17:22:51 +08:00
wjj
3a867e5857 修正短时间多次存现本人口径 2026-06-01 17:22:51 +08:00
wjj
19a60c987e 新增图谱功能及验收清单 2026-06-01 17:22:51 +08:00
wkc
7ce721ef93 Refactor project pages and update related docs 2026-06-01 15:52:50 +08:00
wjj
000e8698a5 优化涉疑交易模型口径和报告展示 2026-06-01 15:52:50 +08:00
wjj
9d3e8beceb 补充项目分析个人详情页正式化样式 2026-06-01 15:50:26 +08:00
wkc
0ea504f6b3 修正双员工夫妻家庭专项核查口径 2026-05-26 17:18:26 +08:00
wkc
a39594faf8 修复风险总览无风险人员负数问题 2026-05-26 16:55:53 +08:00
wkc
1b45296df3 优化资产估值万元展示 2026-05-26 16:53:45 +08:00
wkc
1fadb38d99 Implement credit parse result polling and sentinel handling 2026-05-18 10:56:25 +08:00
wkc
9917d10e59 调整征信解析返回解析和日志 2026-05-13 16:28:57 +08:00
wkc
be443d1b31 Refactor credit parse to use remote HTML paths 2026-05-13 14:20:42 +08:00
wkc
b822cc202e Remove obsolete code and documentation 2026-05-12 17:53:02 +08:00
wkc
598f5dec1c 移除前端默认登录凭据 2026-05-11 16:32:20 +08:00
202 changed files with 13804 additions and 1665 deletions

4
.gitignore vendored
View File

@@ -97,3 +97,7 @@ tongweb_62318.properties
.superpowers/
tmp/
.codegraph/
.claude/

View File

@@ -67,6 +67,7 @@
- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本
- 测试结束后,自动关闭测试过程中启动的前后端进程
- 重启后端时,必须优先使用 `bin/restart_java_backend.sh`
- 禁止在前端源码、配置、示例数据或页面默认值中硬编码或预填真实账号密码;登录页不得将密码保存到 Cookie、localStorage 或 sessionStorage
---
@@ -257,6 +258,7 @@ return AjaxResult.success(result);
- 请求统一使用 `@/utils/request`
- 新增页面或功能入口时,同步检查 `sys_menu`、路由、权限标识
- 优先延续现有 `ccdi*` 业务目录与命名方式,不随意新造平行目录
- 登录页只能在用户主动选择时保存用户名,不允许保存密码或预填默认密码
### 导入功能规范

669
CLAUDE.md
View File

@@ -1,669 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 快速参考
**启动项目:**
- 后端: `mvn spring-boot:run` 或运行 `ry.bat`
- 前端: `cd ruoyi-ui && npm run dev`
**访问地址:**
- 前端: http://localhost:80
- 后端: http://localhost:8080
- Swagger: http://localhost:8080/swagger-ui/index.html
- Druid 监控: http://localhost:8080/druid/ (ruoyi/123456)
**测试账号:**
- 用户名: `admin`
- 密码: `admin123`
**获取 Token:**
```bash
POST http://localhost:8080/login/test?username=admin&password=admin123
```
---
## 项目概述
**纪检初核系统** - 基于 **若依管理系统 v3.9.1** 构建的企业级前后端分离管理系统,用于员工异常行为风险识别。
### 技术栈版本
| 后端技术 | 版本 | 前端技术 | 版本 |
|-----------------------------|--------|------------|---------|
| Spring Boot | 3.5.8 | Vue.js | 2.6.12 |
| Java | 21 | Element UI | 2.15.14 |
| MyBatis Spring Boot Starter | 3.0.5 | Vuex | 3.6.0 |
| MySQL Connector | 8.2.0 | Vue Router | 3.4.9 |
| SpringDoc OpenAPI | 2.8.14 | Axios | 0.28.1 |
| EasyExcel | 3.3.4 | ECharts | 5.4.0 |
| Quartz | 2.5.2 | Sass | 1.32.13 |
---
## 常用命令
### 后端 (Maven)
```bash
# 编译项目
mvn clean compile
# 运行应用 (开发环境)
mvn spring-boot:run
# 打包部署
mvn clean package
# Windows 启动
ry.bat
# Linux/Mac 启动
./ry.sh start
```
### 前端 (npm)
```bash
cd ruoyi-ui
# 安装依赖 (推荐使用国内镜像)
npm install --registry=https://registry.npmmirror.com
# 开发服务器 (端口 80)
npm run dev
# 生产构建
npm run build:prod
# 预览生产构建
npm run preview
```
### 数据库初始化
```bash
# 初始化若依框架基础表
mysql -u root -p < sql/ry_20250522.sql
# 初始化定时任务表
mysql -u root -p < sql/quartz.sql
# 导入业务表(根据需要执行)
mysql -u root -p ccdi < sql/dpc_employee.sql
mysql -u root -p ccdi < sql/dpc_intermediary_blacklist.sql
# ... 其他业务表脚本
```
**注意:**
- 业务表脚本文件名以 `ccdi_``dpc_` 开头
- 部分脚本包含菜单数据,需要按顺序执行
- 数据库需要先创建(数据库名: `ccdi`
---
## 模块架构
```
ccdi/
├── ruoyi-admin/ # 主应用入口 (Spring Boot 启动类)
├── ruoyi-framework/ # 核心框架 (Security, Config, Filters)
├── ruoyi-system/ # 系统管理 (Users, Roles, Menus, Depts)
├── ruoyi-common/ # 通用工具 (annotations, utils, constants)
├── ruoyi-quartz/ # 定时任务
├── ruoyi-generator/ # 代码生成器
├── ccdi-info-collection/ # 【核心业务模块】信息采集
├── ccdi-project/ # 【核心业务模块】项目管理
├── ccdi-lsfx/ # 【核心业务模块】流水分析对接
├── lsfx-mock-server/ # 流水分析模拟服务器 (Python)
├── ruoyi-ui/ # 前端 Vue 应用
├── sql/ # 数据库脚本
├── bin/ # 启动脚本
└── doc/ # 项目文档
```
### 模块依赖关系
```
ruoyi-admin (启动模块)
├── ruoyi-framework (核心安全配置)
├── ruoyi-system (系统核心业务)
├── ruoyi-common (共享工具)
├── ruoyi-quartz (定时任务)
├── ruoyi-generator (代码生成)
├── ccdi-info-collection (信息采集模块)
│ └── 依赖 ruoyi-common
├── ccdi-project (项目管理模块)
│ └── 依赖 ruoyi-common
└── ccdi-lsfx (流水分析对接模块)
└── 依赖 ruoyi-common
```
**添加新业务模块:**
1. 在根目录 `pom.xml``<modules>` 中添加新模块
2. 在新模块的 `pom.xml` 中添加对 `ruoyi-common` 的依赖
3.`ruoyi-admin/pom.xml` 中添加对新模块的依赖
4. 在新模块中按照分层规范创建 controller/service/mapper/domain 包
### ccdi-info-collection 业务模块 (核心)
自定义业务模块,包含以下核心功能:
| 功能 | Controller | 实体类 |
|----------|---------------------------------------|-----------------------------|
| 员工基础信息 | CcdiBaseStaffController | CcdiBaseStaff |
| 中介黑名单 | CcdiIntermediaryController | CcdiBizIntermediary |
| 员工家庭关系 | CcdiStaffFmyRelationController | CcdiStaffFmyRelation |
| 员工企业关系 | CcdiStaffEnterpriseRelationController | CcdiStaffEnterpriseRelation |
| 信贷客户家庭关系 | CcdiCustFmyRelationController | CcdiCustFmyRelation |
| 信贷客户企业关系 | CcdiCustEnterpriseRelationController | CcdiCustEnterpriseRelation |
| 员工调动记录 | CcdiStaffTransferController | CcdiStaffTransfer |
| 员工招聘记录 | CcdiStaffRecruitmentController | CcdiStaffRecruitment |
| 采购交易 | CcdiPurchaseTransactionController | CcdiPurchaseTransaction |
**分层结构:**
- Controller: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/`
- Service: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/`
- Mapper: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/`
- Domain: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/`
- dto/: 数据传输对象
- vo/: 视图对象
- excel/: Excel导入导出实体
- XML映射: `ccdi-info-collection/src/main/resources/mapper/info/collection/`
### ccdi-project 业务模块 (核心)
项目管理模块,用于管理纪检初核项目的全生命周期:
**核心功能:**
- 项目创建、更新、删除、查询
- 项目状态管理 (进行中、已完成、已归档)
- 项目统计(按状态统计数量)
- 模型参数配置管理
**主要 Controller:**
- CcdiProjectController: 项目管理
- CcdiModelParamController: 模型参数配置
**分层结构:**
- Controller: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/`
- Service: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/`
- Mapper: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/`
- Domain: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/`
- XML映射: `ccdi-project/src/main/resources/mapper/ccdi/project/`
### ccdi-lsfx 业务模块 (核心)
流水分析平台对接模块,用于与外部流水分析系统交互:
**核心功能:**
- 获取访问令牌 (Token)
- 上传流水文件并解析
- 拉取行内流水数据
- 查询解析状态和结果
- 获取银行流水明细
**主要组件:**
- LsfxAnalysisClient: 流水分析平台客户端
- LsfxTestController: 测试接口
**配置项 (application-dev.yml):**
```yaml
lsfx:
api:
base-url: http://localhost:8000 # 流水分析平台地址
app-id: your-app-id
app-secret: your-app-secret
client-id: your-client-id
endpoints:
get-token: /api/auth/token
upload-file: /api/files/upload
fetch-inner-flow: /api/flow/inner
```
**分层结构:**
- Client: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/`
- Controller: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/`
- Domain: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/`
- request/: 请求对象
- response/: 响应对象
- Config: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/config/`
### lsfx-mock-server (开发测试工具)
Python 实现的流水分析平台模拟服务器,用于本地开发和测试:
**用途:**
- 模拟流水分析平台的 API 接口
- 提供测试数据和模拟响应
- 支持错误场景模拟
**启动方式:**
```bash
cd lsfx-mock-server
python app.py # 默认监听 http://localhost:8000
```
---
## 后端开发规范
### 通用规范
- **新模块命名**: 项目英文名首字母集合 + 主要功能 (如 `ruoyi-info-collection`)
- **代码分离**: 新功能代码与若依框架自带代码分离Controller 放在新模块中
- **审计字段**: 实体类不继承 BaseEntity单独添加审计字段通过注释实现自动插入
### Java 代码风格
```java
// 使用 @Data 注解
@Data
public class CcdiBaseStaff {
// 审计字段通过注释实现自动插入
/** 创建者 */
private String createBy;
/** 创建时间 */
private Date createTime;
/** 更新者 */
private String updateBy;
/** 更新时间 */
private Date updateTime;
}
// 服务层使用 @Resource 注入
@Resource
private ICcdiBaseStaffService baseStaffService;
```
### 分层规范
- **Controller**: 所有接口添加 Swagger 注释,分页使用 MyBatis Plus Page
- **Service**: 简单 CRUD 用 MyBatis Plus 方法,复杂操作在 XML 写 SQL
- **DTO/VO**: 接口传参使用独立 DTO返回使用独立 VO不与 entity 混用
- **Mapper**: 简单操作继承 BaseMapper复杂操作在 XML 中定义
### 禁止事项
- **禁止使用全限定类名**: 必须使用 `import` 语句导入类,不要在代码中使用 `java.util.List` 这样的全限定名
- **禁止使用 `extends ServiceImpl<>`**: Service 接口和实现类分离定义
- **禁止 Entity 混用**: DTO、VO、Excel 类必须独立,不与 Entity 混用
- **禁止缺少 `@Resource`**: Service 注入必须使用 `@Resource` 注解
### API 响应格式
```java
// 成功
AjaxResult.success("操作成功", data);
// 错误
AjaxResult.error("操作失败");
// 分页
Page<CcdiBaseStaff> page = new Page<>(pageNum, pageSize);
IPage<CcdiBaseStaff> result = baseStaffMapper.selectPage(page, queryWrapper);
return AjaxResult.success(result);
```
---
## 前端开发规范
### 目录结构
```
ruoyi-ui/src/
├── api/ # API 请求定义 (与后端 Controller 对应)
├── views/ # 页面组件 (按功能模块组织)
│ ├── ccdiBaseStaff/
│ ├── ccdiIntermediary/
│ └── ...
├── components/ # 可复用组件 (复杂组件需拆分)
├── router/ # 路由配置
└── store/ # Vuex 状态管理
```
### API 调用示例
```javascript
import request from '@/utils/request'
export function listStaff(query) {
return request({
url: '/ccdi/baseStaff/list',
method: 'get',
params: query
})
}
```
### 菜单联动
添加页面和组件后,需要同步修改数据库中的菜单表 (`sys_menu`)。
---
## 特殊功能
### 异步导入
支持大数据量异步 Excel 导入,通过 taskId 查询导入状态:
```java
@PostMapping("/import")
public AjaxResult asyncImport(@RequestParam("file") MultipartFile file) {
String taskId = asyncImportService.startImport(file);
return AjaxResult.success("导入任务已启动", taskId);
}
@GetMapping("/import/status/{taskId}")
public AjaxResult getImportStatus(@PathVariable String taskId) {
return AjaxResult.success(asyncImportService.getStatus(taskId));
}
```
**导入流程:**
1. 前端上传 Excel 文件
2. 后端异步处理,返回 taskId
3. 前端轮询 `/import/status/{taskId}` 获取导入进度
4. 导入完成后,可获取成功/失败数据统计
**导入结果处理:**
- 只返回导入失败的数据(含失败原因)
- 成功数据不返回,减少响应体积
- 支持批量插入,提高性能
### EasyExcel 字典下拉框
导入模板支持字典下拉框配置,提升数据录入准确性。使用 `DictDropdownWriteHandler` 实现。
### 权限控制
基于 Spring Security + JWT 的角色菜单权限系统:
- 权限格式: `system:user:edit`, `ccdi:staff:list`
- 数据权限: 支持全部、自定义、部门等范围
---
## 测试与验证
### 测试账号
- **用户名**: `admin`
- **密码**: `admin123`
### 登录获取 Token
```bash
# 登录接口
POST /login/test?username=admin&password=admin123
```
### API 文档
- **Swagger UI**: `/swagger-ui/index.html`
- **API Docs**: `/v3/api-docs`
### 测试规范
- 不在命令行启动后端进行测试
- 生成可执行的测试脚本进行验证
- 测试完成后保存接口输出并生成测试用例报告
### 开发调试技巧
**使用 Swagger 测试接口:**
1. 访问 `/swagger-ui/index.html`
2. 点击接口展开详情
3. 点击 "Try it out" 进行测试
4. 填写参数后点击 "Execute" 执行
**查看 SQL 执行日志:**
-`application.yml` 中设置日志级别: `com.ruoyi: debug`
- 使用 Druid 监控台查看慢 SQL
**前端代理配置:**
前端开发服务器通过代理转发请求到后端:
- 前端地址: `http://localhost:80`
- 后端地址: `http://localhost:8080`
- 代理配置文件: `ruoyi-ui/vue.config.js`
---
## 配置说明
| 配置项 | 值 |
|---------|-------------------|
| 后端端口 | 8080 |
| 前端开发端口 | 80 |
| 默认管理员 | admin/admin123 |
| JWT 有效期 | 30 分钟 |
| 文件上传限制 | 单文件 10MB, 总计 20MB |
### 配置文件位置
| 配置 | 路径 |
|----------|------------------------------------------------------|
| 主配置 | `ruoyi-admin/src/main/resources/application.yml` |
| 开发环境 | `ruoyi-admin/src/main/resources/application-dev.yml` |
| 数据库连接 | `application-dev.yml` |
| Redis 配置 | `application-dev.yml` |
### 数据源配置
项目使用 Druid 连接池,支持主从分离(默认关闭从库):
- **数据库连接**: `jdbc:mysql://host:3306/ccdi`
- **初始连接数**: 5
- **最小连接数**: 10
- **最大连接数**: 20
- **慢 SQL 记录**: 超过 1000ms 的 SQL 会被记录
### Redis 配置
- **默认端口**: 6379
- **数据库索引**: 0
- **连接超时**: 10s
### 流水分析平台配置
项目集成了外部流水分析平台,配置项位于 `application-dev.yml`:
```yaml
lsfx:
api:
base-url: http://localhost:8000 # 流水分析平台基础地址
app-id: ccdi-app # 应用ID
app-secret: ccdi-secret-2024 # 应用密钥
client-id: ccdi-client # 客户端ID
endpoints:
get-token: /api/auth/token # 获取令牌接口
upload-file: /api/files/upload # 文件上传接口
fetch-inner-flow: /api/flow/inner # 拉取行内流水接口
```
**开发环境使用 Mock 服务器:**
- 本地开发时,将 `base-url` 设置为 `http://localhost:8000`
- 启动 `lsfx-mock-server` 提供模拟接口
- 生产环境替换为真实的流水分析平台地址
### MCP 配置
项目使用 MCP (Model Context Protocol) 连接数据库,配置文件: `.mcp.json`
```json
{
"mcpServers": {
"mysql": {
"command": "npx",
"args": ["-y", "@fhuang/mcp-mysql-server"],
"env": {
"MYSQL_HOST": "116.62.17.81",
"MYSQL_PORT": "3306",
"MYSQL_USER": "root",
"MYSQL_PASSWORD": "Kfcx@1234",
"MYSQL_DATABASE": "ccdi"
}
}
}
}
```
**使用场景:**
- 通过 MCP 工具直接查询和操作数据库
- 在开发过程中快速验证数据
- 生成测试数据和调试 SQL
### Druid 监控台
访问地址: `http://localhost:8080/druid/`
- 用户名: `ruoyi`
- 密码: `123456`
用于监控 SQL 执行情况、连接池状态等。
---
## 重要文件路径
| 用途 | 路径 |
|---------------|--------------------------------------------------------------------------------|
| 应用入口 | `ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java` |
| 安全配置 | `ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java` |
| 信息采集 Controller | `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/` |
| 信息采集 Mapper XML | `ccdi-info-collection/src/main/resources/mapper/info/collection/` |
| 项目管理 Controller | `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/` |
| 项目管理 Mapper XML | `ccdi-project/src/main/resources/mapper/ccdi/project/` |
| 流水分析 Client | `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java` |
| Vue 路由 | `ruoyi-ui/src/router/index.js` |
| Vuex Store | `ruoyi-ui/src/store/` |
| 前端 API | `ruoyi-ui/src/api/` |
---
## 数据库规范
- **新建表名**: 需要加上项目英文名首字母集合前缀 `ccdi_` (如 `ccdi_base_staff`)
---
## 文档管理
- **文档语言**: 使用简体中文编写 .md 文档
- **文档目录**: 所有生成的文档放在 `doc/` 目录下,按类型分类
- **需求分析**: 在 `doc/` 目录下新建文件夹,以需求内容命名
### doc 目录结构
```
doc/
├── api-docs/ # API 文档
├── database/ # 数据库相关
├── design/ # 设计文档
├── implementation/ # 实施文档
├── requirements/ # 需求文档
└── test-scripts/ # 测试脚本
```
---
## OpenSpec 工作流
项目使用 OpenSpec 进行规范驱动开发,参考 `openspec/AGENTS.md`
### 何时创建 Proposal
**需要创建:**
- 新功能或能力
- 破坏性变更 (API, 数据库结构)
- 架构变更
- 改变行为的性能优化
**无需创建:**
- Bug 修复 (恢复预期行为)
- 拼写错误、格式、注释
- 非破坏性依赖更新
- 配置变更
---
## 沟通规范
- 永远使用简体中文进行思考和对话
---
## 常见问题排查
### 数据库连接失败
**检查项:**
1. 确认 MySQL 服务已启动
2. 检查 `application-dev.yml` 中的数据库连接配置
3. 确认数据库用户名和密码正确
4. 检查数据库是否已创建(数据库名: `ccdi`
### Redis 连接失败
**检查项:**
1. 确认 Redis 服务已启动
2. 检查 `application-dev.yml` 中的 Redis 配置
3. 如果 Redis 不需要密码,将 `password` 配置注释掉
### 前端无法访问后端接口
**检查项:**
1. 确认后端已启动(端口 8080
2. 检查前端代理配置(`ruoyi-ui/vue.config.js`
3. 确认后端接口路径正确(查看 Controller 的 `@RequestMapping`
### 导入功能无响应
**检查项:**
1. 检查文件大小是否超过限制(默认 10MB
2. 查看后端日志是否有异常
3. 确认 Excel 模板格式正确
4. 检查必填字段是否为空
### 流水分析平台连接失败
**检查项:**
1. 确认 `lsfx-mock-server` 已启动(开发环境)
2. 检查 `application-dev.yml` 中的 `lsfx.api.base-url` 配置
3. 验证 app-id、app-secret、client-id 是否正确
4. 检查网络连接和防火墙设置
5. 查看后端日志中的 HTTP 请求错误信息
---
## MyBatis Plus 分页使用
```java
// Controller 层
@GetMapping("/list")
public TableDataInfo list(QueryDTO queryDTO) {
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<VO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<VO> result = service.selectPage(page, queryDTO);
return getDataTable(result.getRecords(), result.getTotal());
}
// Service 层
Page<VO> selectPage(Page<VO> page, QueryDTO queryDTO);
// Mapper 层 (使用 XML)
<select id="selectPage" resultType="VO">
SELECT * FROM table_name
<where>
<if test="queryDTO.name != null">
AND name LIKE CONCAT('%', #{queryDTO.name}, '%')
</if>
</where>
</select>
```

View File

@@ -1,92 +0,0 @@
#!/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

@@ -14,8 +14,10 @@ import com.ruoyi.info.collection.mapper.CcdiCreditInfoQueryMapper;
import com.ruoyi.info.collection.mapper.CcdiCreditNegativeInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiDebtsInfoMapper;
import com.ruoyi.info.collection.service.ICcdiCreditInfoService;
import com.ruoyi.info.collection.service.support.CreditHtmlStorageService;
import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler;
import com.ruoyi.lsfx.client.CreditParseClient;
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
import com.ruoyi.lsfx.domain.response.CreditParsePayload;
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
import jakarta.annotation.Resource;
@@ -23,8 +25,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@@ -36,9 +36,16 @@ import java.util.Map;
@Service
public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
private static final int CREDIT_PARSE_SUCCESS_CODE = 10000;
private static final int CREDIT_PARSE_SUCCESS_STATUS = 1;
private static final int CREDIT_PARSE_SUCCESS_REASON_CODE = 200;
@Resource
private CreditParseClient creditParseClient;
@Resource
private CreditHtmlStorageService creditHtmlStorageService;
@Resource
private CreditInfoPayloadAssembler assembler;
@@ -141,37 +148,20 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
}
}
private void handleSingleFile(MultipartFile multipartFile, String userName) throws IOException {
File tempFile = createTempFile(multipartFile);
try {
CreditParseResponse response = creditParseClient.parse("LXCUSTALL", "PERSON", tempFile);
CreditParsePayload payload = requireResponse(response).getPayload();
Map<String, Object> header = requireHeader(payload);
String personId = stringValue(header.get("query_cert_no"));
String personName = stringValue(header.get("query_cust_name"));
LocalDate queryDate = parseQueryDate(stringValue(header.get("report_time")));
ensurePersonIdPresent(personId);
ensureLatestQueryDate(personId, queryDate);
private void handleSingleFile(MultipartFile multipartFile, String userName) throws Exception {
CreditHtmlStorageService.StoredCreditHtml storedHtml = creditHtmlStorageService.save(multipartFile);
CreditParseInvokeResponse response = creditParseClient.parse(storedHtml.remotePath());
CreditParsePayload payload = requireResponse(response).getPayload();
Map<String, Object> header = requireHeader(payload);
String personId = stringValue(header.get("query_cert_no"));
String personName = stringValue(header.get("query_cust_name"));
LocalDate queryDate = parseQueryDate(stringValue(header.get("report_time")));
ensurePersonIdPresent(personId);
ensureLatestQueryDate(personId, queryDate);
List<CcdiDebtsInfo> debts = assembler.buildDebts(personId, personName, queryDate, payload);
CcdiCreditNegativeInfo negative = assembler.buildNegative(personId, personName, queryDate, payload);
replaceEmployeeCredit(personId, debts, negative, userName);
} finally {
if (tempFile.exists()) {
tempFile.delete();
}
}
}
private File createTempFile(MultipartFile multipartFile) throws IOException {
String originalFilename = multipartFile.getOriginalFilename();
String suffix = ".html";
if (originalFilename != null && originalFilename.contains(".")) {
suffix = originalFilename.substring(originalFilename.lastIndexOf('.'));
}
File tempFile = File.createTempFile("credit-info-", suffix);
multipartFile.transferTo(tempFile);
return tempFile;
List<CcdiDebtsInfo> debts = assembler.buildDebts(personId, personName, queryDate, payload);
CcdiCreditNegativeInfo negative = assembler.buildNegative(personId, personName, queryDate, payload);
replaceEmployeeCredit(personId, debts, negative, userName);
}
private void validateHtmlFile(MultipartFile file) {
@@ -185,14 +175,41 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService {
}
}
private CreditParseResponse requireResponse(CreditParseResponse response) {
if (response == null || response.getPayload() == null) {
private CreditParseResponse requireResponse(CreditParseInvokeResponse response) {
if (response == null || response.getData() == null || response.getData().getMappingOutputFields() == null) {
throw new RuntimeException("征信解析结果为空");
}
if (!"0".equals(response.getStatusCode())) {
throw new RuntimeException(stringValue(response.getMessage(), "征信解析失败"));
CreditParseResponse mappingOutputFields = response.getData().getMappingOutputFields();
if (!Boolean.TRUE.equals(response.getSuccess())) {
throw new RuntimeException(stringValue(mappingOutputFields.getMessage(), "征信解析平台调用失败"));
}
return response;
if (!Integer.valueOf(CREDIT_PARSE_SUCCESS_CODE).equals(response.getCode())) {
throw new RuntimeException("征信解析平台状态码异常: " + response.getCode());
}
if (!Integer.valueOf(CREDIT_PARSE_SUCCESS_STATUS).equals(response.getData().getStatus())) {
throw new RuntimeException(parseErrorMessage(response, "征信解析状态异常: " + response.getData().getStatus()));
}
if (!Integer.valueOf(CREDIT_PARSE_SUCCESS_REASON_CODE).equals(response.getData().getReasonCode())) {
throw new RuntimeException(parseErrorMessage(response, "征信解析原因码异常: " + response.getData().getReasonCode()));
}
if (mappingOutputFields.getPayload() == null) {
throw new RuntimeException("征信解析结果为空");
}
return mappingOutputFields;
}
private String parseErrorMessage(CreditParseInvokeResponse response, String defaultValue) {
if (response == null || response.getData() == null) {
return defaultValue;
}
String reasonMessage = stringValue(response.getData().getReasonMessage());
if (!isBlank(reasonMessage)) {
return reasonMessage;
}
if (response.getData().getMappingOutputFields() != null) {
return stringValue(response.getData().getMappingOutputFields().getMessage(), defaultValue);
}
return defaultValue;
}
private Map<String, Object> requireHeader(CreditParsePayload payload) {

View File

@@ -0,0 +1,44 @@
package com.ruoyi.info.collection.service.support;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.file.FileUploadUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
/**
* 征信 HTML 服务器落盘与远程访问地址生成。
*/
@Component
public class CreditHtmlStorageService {
private static final String CREDIT_HTML_DIR = "credit-html";
private static final String[] HTML_EXTENSIONS = {"html", "htm"};
@Value("${credit-parse.api.file-public-base-url}")
private String filePublicBaseUrl;
public StoredCreditHtml save(MultipartFile file) throws Exception {
String profilePath = FileUploadUtils.upload(getCreditHtmlBaseDir(), file, HTML_EXTENSIONS);
return new StoredCreditHtml(profilePath, buildRemotePath(profilePath));
}
private String getCreditHtmlBaseDir() {
return RuoYiConfig.getProfile() + File.separator + CREDIT_HTML_DIR;
}
private String buildRemotePath(String profilePath) {
if (StringUtils.isBlank(filePublicBaseUrl)) {
throw new IllegalStateException("征信HTML公开访问地址未配置");
}
String normalizedBaseUrl = StringUtils.stripEnd(filePublicBaseUrl.trim(), "/");
String normalizedProfilePath = profilePath.startsWith("/") ? profilePath : "/" + profilePath;
return normalizedBaseUrl + normalizedProfilePath;
}
public record StoredCreditHtml(String profilePath, String remotePath) {
}
}

View File

@@ -19,6 +19,8 @@ import java.util.Objects;
@Component
public class CreditInfoPayloadAssembler {
private static final BigDecimal MISSING_SENTINEL = new BigDecimal("-9999");
private static final List<DebtMapping> DEBT_MAPPINGS = List.of(
new DebtMapping("uncle_bank_house", "银行", "住房贷款", "银行", "未结清银行住房贷款"),
new DebtMapping("uncle_bank_car", "银行", "汽车贷款", "银行", "未结清银行汽车贷款"),
@@ -26,7 +28,7 @@ public class CreditInfoPayloadAssembler {
new DebtMapping("uncle_bank_consume", "银行", "消费贷款", "银行", "未结清银行消费贷款"),
new DebtMapping("uncle_bank_other", "银行", "其他贷款", "银行", "未结清银行其他贷款"),
new DebtMapping("uncle_not_bank", "非银", "非银行贷款", "非银", "未结清非银行贷款"),
new DebtMapping("uncle_credit_cart", "银行", "信用卡", "银行", "未结清信用卡")
new DebtMapping("uncle_credit_card", "银行", "信用卡", "银行", "未结清信用卡")
);
public List<CcdiDebtsInfo> buildDebts(String personId, String personName, LocalDate queryDate, CreditParsePayload payload) {
@@ -61,9 +63,13 @@ public class CreditInfoPayloadAssembler {
private CcdiDebtsInfo buildDebtRow(String personId, String personName, LocalDate queryDate,
Map<String, Object> source, DebtMapping mapping) {
Object stateValue = source.get(mapping.prefix() + "_state");
if (isMissingSentinel(stateValue)) {
return null;
}
BigDecimal principalBalance = toBigDecimal(source.get(mapping.prefix() + "_bal"));
BigDecimal debtTotalAmount = toBigDecimal(source.get(mapping.prefix() + "_lmt"));
String debtStatus = toStringValue(source.get(mapping.prefix() + "_state"));
String debtStatus = toStringValue(stateValue);
if (isEmptyMetrics(principalBalance, debtTotalAmount, debtStatus)) {
return null;
}
@@ -97,6 +103,9 @@ public class CreditInfoPayloadAssembler {
if (value == null) {
return null;
}
if (isMissingSentinel(value)) {
return null;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
@@ -111,10 +120,28 @@ public class CreditInfoPayloadAssembler {
if (value == null) {
return null;
}
if (isMissingSentinel(value)) {
return null;
}
String text = Objects.toString(value, "").trim();
return text.isEmpty() ? null : text;
}
private boolean isMissingSentinel(Object value) {
if (value == null) {
return false;
}
String text = Objects.toString(value, "").trim();
if (text.isEmpty()) {
return false;
}
try {
return new BigDecimal(text).compareTo(MISSING_SENTINEL) == 0;
} catch (NumberFormatException e) {
return false;
}
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}

View File

@@ -84,6 +84,7 @@
<sql id="AccountInfoWhereClause">
WHERE 1 = 1
AND ai.owner_type &lt;&gt; 'CREDIT_CUSTOMER'
<if test="query.staffName != null and query.staffName != ''">
AND (
(ai.owner_type = 'EMPLOYEE' AND bs.name LIKE CONCAT('%', #{query.staffName}, '%'))

View File

@@ -35,6 +35,7 @@ class CcdiAccountInfoMapperTest {
assertTrue(sql.contains("ai.is_self_account as isactualcontrol"), sql);
assertTrue(sql.contains("ai.monthly_avg_trans_count as avgmonthtxncount"), sql);
assertTrue(sql.contains("ai.trans_risk_level as txnrisklevel"), sql);
assertTrue(sql.contains("ai.owner_type <> 'credit_customer'"), sql);
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {

View File

@@ -1,5 +1,6 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.info.collection.domain.CcdiCreditNegativeInfo;
import com.ruoyi.info.collection.domain.CcdiDebtsInfo;
import com.ruoyi.info.collection.domain.vo.CreditInfoUploadResultVO;
@@ -7,26 +8,33 @@ import com.ruoyi.info.collection.mapper.CcdiCreditInfoQueryMapper;
import com.ruoyi.info.collection.mapper.CcdiCreditNegativeInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiDebtsInfoMapper;
import com.ruoyi.info.collection.service.impl.CcdiCreditInfoServiceImpl;
import com.ruoyi.info.collection.service.support.CreditHtmlStorageService;
import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler;
import com.ruoyi.lsfx.client.CreditParseClient;
import com.ruoyi.lsfx.domain.response.CreditParseInvokeData;
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
import com.ruoyi.lsfx.domain.response.CreditParsePayload;
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.File;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
@@ -41,6 +49,9 @@ class CcdiCreditInfoServiceImplTest {
@Mock
private CreditParseClient creditParseClient;
@Mock
private CreditHtmlStorageService creditHtmlStorageService;
@Mock
private CreditInfoPayloadAssembler assembler;
@@ -54,11 +65,15 @@ class CcdiCreditInfoServiceImplTest {
private CcdiCreditInfoQueryMapper queryMapper;
@Test
void uploadHtmlFiles_shouldStoreCreditObjectWithoutStaffBinding() {
void uploadHtmlFiles_shouldStoreCreditObjectWithoutStaffBinding() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"files", "family.html", "text/html", "<html>ok</html>".getBytes(StandardCharsets.UTF_8));
when(creditParseClient.parse(anyString(), anyString(), any(File.class)))
when(creditHtmlStorageService.save(any()))
.thenReturn(new CreditHtmlStorageService.StoredCreditHtml(
"/profile/credit-html/2026/05/12/family_1.html",
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html"));
when(creditParseClient.parse(anyString()))
.thenReturn(successResponse("330101199202020022", "李四", "2026-03-24"));
when(assembler.buildDebts(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class)))
.thenReturn(List.of(buildDebt("330101199202020022")));
@@ -69,15 +84,20 @@ class CcdiCreditInfoServiceImplTest {
assertEquals(1, result.getSuccessCount());
assertEquals(0, result.getFailureCount());
verify(creditParseClient).parse("http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html");
verify(debtsInfoMapper).deleteByPersonId("330101199202020022");
verify(negativeInfoMapper).deleteByPersonId("330101199202020022");
}
@Test
void uploadHtmlFiles_shouldRejectOlderReportDate() {
void uploadHtmlFiles_shouldRejectOlderReportDate() throws Exception {
MockMultipartFile file = new MockMultipartFile("files", "a.html", "text/html", "<html>a</html>".getBytes(StandardCharsets.UTF_8));
when(creditParseClient.parse(anyString(), anyString(), any(File.class)))
when(creditHtmlStorageService.save(any()))
.thenReturn(new CreditHtmlStorageService.StoredCreditHtml(
"/profile/credit-html/2026/05/12/a_1.html",
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html"));
when(creditParseClient.parse(anyString()))
.thenReturn(successResponse("330101199001010011", "张三", "2026-03-03"));
when(queryMapper.selectLatestQueryDate("330101199001010011"))
.thenReturn(LocalDate.parse("2026-03-05"));
@@ -88,7 +108,68 @@ class CcdiCreditInfoServiceImplTest {
assertEquals("上传征信日期早于当前已维护最新记录", result.getFailures().get(0).getReason());
}
private CreditParseResponse successResponse(String personId, String personName, String reportTime) {
@Test
void uploadHtmlFiles_shouldRejectInvalidPlatformCode() throws Exception {
MockMultipartFile file = new MockMultipartFile("files", "a.html", "text/html", "<html>a</html>".getBytes(StandardCharsets.UTF_8));
when(creditHtmlStorageService.save(any()))
.thenReturn(new CreditHtmlStorageService.StoredCreditHtml(
"/profile/credit-html/2026/05/12/a_1.html",
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html"));
CreditParseInvokeResponse response = successResponse("330101199001010011", "张三", "2026-03-03");
response.setCode(99999);
when(creditParseClient.parse(anyString())).thenReturn(response);
CreditInfoUploadResultVO result = service.upload(List.of(file));
assertEquals(0, result.getSuccessCount());
assertEquals("征信解析平台状态码异常: 99999", result.getFailures().get(0).getReason());
}
@Test
void uploadHtmlFiles_shouldRejectInvalidResultStatus() throws Exception {
MockMultipartFile file = new MockMultipartFile("files", "a.html", "text/html", "<html>a</html>".getBytes(StandardCharsets.UTF_8));
when(creditHtmlStorageService.save(any()))
.thenReturn(new CreditHtmlStorageService.StoredCreditHtml(
"/profile/credit-html/2026/05/12/a_1.html",
"http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html"));
CreditParseInvokeResponse response = successResponse("330101199001010011", "张三", "2026-03-03");
response.getData().setStatus(0);
response.getData().setReasonCode(500);
response.getData().setReasonMessage("结果解析失败");
when(creditParseClient.parse(anyString())).thenReturn(response);
CreditInfoUploadResultVO result = service.upload(List.of(file));
assertEquals(0, result.getSuccessCount());
assertEquals("结果解析失败", result.getFailures().get(0).getReason());
}
@Test
void creditHtmlStorage_shouldStoreHtmlUnderProfileAndBuildRemotePath(@TempDir Path profileDir) throws Exception {
String oldProfile = RuoYiConfig.getProfile();
new RuoYiConfig().setProfile(profileDir.toString());
try {
CreditHtmlStorageService storageService = new CreditHtmlStorageService();
ReflectionTestUtils.setField(storageService, "filePublicBaseUrl", "http://127.0.0.1:62318/");
MockMultipartFile file = new MockMultipartFile(
"files", "credit.html", "text/html", "<html>ok</html>".getBytes(StandardCharsets.UTF_8));
CreditHtmlStorageService.StoredCreditHtml storedHtml = storageService.save(file);
assertTrue(storedHtml.profilePath().startsWith("/profile/credit-html/"));
assertTrue(storedHtml.profilePath().endsWith(".html"));
assertEquals("http://127.0.0.1:62318" + storedHtml.profilePath(), storedHtml.remotePath());
Path savedFile = profileDir.resolve(storedHtml.profilePath().substring("/profile/".length()));
assertTrue(Files.exists(savedFile));
} finally {
new RuoYiConfig().setProfile(oldProfile);
}
}
private CreditParseInvokeResponse successResponse(String personId, String personName, String reportTime) {
CreditParsePayload payload = new CreditParsePayload();
Map<String, Object> header = new HashMap<>();
header.put("query_cert_no", personId);
@@ -99,9 +180,20 @@ class CcdiCreditInfoServiceImplTest {
payload.setLxPublictype(Map.of("civil_cnt", 1));
CreditParseResponse response = new CreditParseResponse();
response.setStatusCode("0");
response.setMessage("成功");
response.setStatusCode("ERR_SHOULD_IGNORE");
response.setPayload(payload);
return response;
CreditParseInvokeData data = new CreditParseInvokeData();
data.setMappingOutputFields(response);
data.setStatus(1);
data.setReasonCode(200);
CreditParseInvokeResponse invokeResponse = new CreditParseInvokeResponse();
invokeResponse.setSuccess(true);
invokeResponse.setCode(10000);
invokeResponse.setData(data);
return invokeResponse;
}
private CcdiDebtsInfo buildDebt(String personId) {

View File

@@ -12,6 +12,7 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CreditInfoPayloadAssemblerTest {
@@ -28,13 +29,18 @@ class CreditInfoPayloadAssemblerTest {
debt.put("uncle_not_bank_bal", "2000");
debt.put("uncle_not_bank_lmt", "3000");
debt.put("uncle_not_bank_state", "逾期");
debt.put("uncle_credit_card_bal", "100");
debt.put("uncle_credit_card_lmt", "500");
debt.put("uncle_credit_card_state", "正常");
payload.setLxDebt(debt);
List<CcdiDebtsInfo> rows = assembler.buildDebts("330101199001010011", "张三", LocalDate.parse("2026-03-01"), payload);
assertEquals(2, rows.size());
assertEquals(3, rows.size());
assertEquals("住房贷款", rows.get(0).getDebtSubType());
assertEquals("非银", rows.get(1).getCreditorType());
assertEquals("信用卡", rows.get(2).getDebtSubType());
assertEquals(new BigDecimal("500"), rows.get(2).getDebtTotalAmount());
}
@Test
@@ -51,6 +57,42 @@ class CreditInfoPayloadAssemblerTest {
assertEquals(new BigDecimal("9800"), info.getCivilLmt());
}
@Test
void shouldSkipDebtTypeWhenStateIsMissingSentinel() {
CreditParsePayload payload = new CreditParsePayload();
Map<String, Object> debt = new HashMap<>();
debt.put("uncle_bank_house_bal", "50000");
debt.put("uncle_bank_house_lmt", "100000");
debt.put("uncle_bank_house_state", "-9999");
debt.put("uncle_not_bank_bal", "2000");
debt.put("uncle_not_bank_lmt", "3000");
debt.put("uncle_not_bank_state", "正常");
payload.setLxDebt(debt);
List<CcdiDebtsInfo> rows = assembler.buildDebts("330101199001010011", "张三", LocalDate.parse("2026-03-01"), payload);
assertEquals(1, rows.size());
assertEquals("非银行贷款", rows.get(0).getDebtSubType());
}
@Test
void shouldTreatNegativeRiskMissingSentinelAsEmptyValue() {
CreditParsePayload payload = new CreditParsePayload();
Map<String, Object> publictype = new HashMap<>();
publictype.put("civil_cnt", "-9999");
publictype.put("civil_lmt", "-9999.0");
publictype.put("enforce_cnt", 1);
publictype.put("enforce_lmt", "1200");
payload.setLxPublictype(publictype);
CcdiCreditNegativeInfo info = assembler.buildNegative("330101199001010011", "张三", LocalDate.parse("2026-03-01"), payload);
assertEquals(0, info.getCivilCnt());
assertNull(info.getCivilLmt());
assertEquals(1, info.getEnforceCnt());
assertEquals(new BigDecimal("1200"), info.getEnforceLmt());
}
@Test
void shouldSkipDebtRowWhenAllMetricsAreEmpty() {
CreditParsePayload payload = new CreditParsePayload();

View File

@@ -1,6 +1,9 @@
package com.ruoyi.lsfx.client;
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
import com.ruoyi.lsfx.exception.LsfxApiException;
import com.ruoyi.lsfx.util.HttpUtil;
import jakarta.annotation.Resource;
@@ -8,7 +11,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
@@ -16,32 +18,191 @@ import java.util.Map;
@Component
public class CreditParseClient {
private static final int PLATFORM_SUCCESS_CODE = 10000;
private static final int INITIATE_SUCCESS_STATUS = 1;
private static final int INITIATE_SUCCESS_REASON_CODE = 200;
private static final int RESULT_QUERY_MAX_ATTEMPTS = 5;
private static final long RESULT_QUERY_INTERVAL_MILLIS = 2000L;
@Resource
private HttpUtil httpUtil;
@Resource
private ObjectMapper objectMapper;
@Value("${credit-parse.api.url}")
private String creditParseUrl;
public CreditParseResponse parse(String model, String hType, File file) {
@Value("${credit-parse.api.result-url}")
private String creditParseResultUrl;
@Value("${credit-parse.api.org-code:999000}")
private String orgCode;
@Value("${credit-parse.api.run-type:1}")
private String runType;
@Value("${credit-parse.api.model:LXCUSTALL}")
private String defaultModel;
public CreditParseInvokeResponse parse(String remotePath) {
return parse(defaultModel, remotePath);
}
public CreditParseInvokeResponse parse(String model, String remotePath) {
long startTime = System.currentTimeMillis();
log.info("【征信解析】开始调用: fileName={}, model={}, hType={}", file.getName(), model, hType);
String actualModel = StringUtils.isBlank(model) ? defaultModel : model;
String serialNum = buildSerialNum();
try {
Map<String, Object> params = new HashMap<>();
params.put("model", model);
params.put("hType", hType);
params.put("file", file);
Map<String, Object> initiateParams = buildInitiateParams(serialNum, actualModel, remotePath);
CreditParseInvokeResponse initiateResponse = request(creditParseUrl, initiateParams, "发起接口");
requireSuccessfulInitiateResponse(initiateResponse, "征信解析发起接口");
CreditParseResponse response = httpUtil.uploadFile(creditParseUrl, params, null, CreditParseResponse.class);
CreditParseInvokeResponse response = queryResult(serialNum);
long elapsed = System.currentTimeMillis() - startTime;
log.info("【征信解析】调用完成: statusCode={}, cost={}ms",
response != null ? response.getStatusCode() : null, elapsed);
log.info("【征信解析】调用完成: success={}, code={}, businessStatusCode={}, cost={}ms",
response != null ? response.getSuccess() : null,
response != null ? response.getCode() : null,
response != null && response.getData() != null && response.getData().getMappingOutputFields() != null
? response.getData().getMappingOutputFields().getStatusCode() : null,
elapsed);
return response;
} catch (LsfxApiException e) {
log.error("【征信解析】调用失败: serialNum={}, model={}, remotePath={}, error={}",
serialNum, actualModel, remotePath, e.getMessage(), e);
throw e;
} catch (Exception e) {
log.error("【征信解析】调用失败: fileName={}, model={}, hType={}, error={}",
file.getName(), model, hType, e.getMessage(), e);
log.error("【征信解析】调用失败: serialNum={}, model={}, remotePath={}, error={}",
serialNum, actualModel, remotePath, e.getMessage(), e);
throw new LsfxApiException("征信解析调用失败: " + e.getMessage(), e);
}
}
private Map<String, Object> buildInitiateParams(String serialNum, String model, String remotePath) {
Map<String, Object> params = buildBaseParams(serialNum);
params.put("remotePath", remotePath);
params.put("model", model);
return params;
}
private Map<String, Object> buildBaseParams(String serialNum) {
Map<String, Object> params = new HashMap<>();
params.put("serialNum", serialNum);
params.put("orgCode", orgCode);
params.put("runType", runType);
return params;
}
private CreditParseInvokeResponse queryResult(String serialNum) {
Map<String, Object> params = buildBaseParams(serialNum);
for (int attempt = 1; attempt <= RESULT_QUERY_MAX_ATTEMPTS; attempt++) {
CreditParseInvokeResponse response = request(creditParseResultUrl, params,
"结果接口第" + attempt + "次查询");
requireSuccessfulServiceResponse(response, "征信解析结果接口");
if (response.getData() == null || response.getData().getMappingOutputFields() == null) {
waitForNextResult(serialNum, attempt);
continue;
}
if (response.getData().getMappingOutputFields().getPayload() != null) {
return response;
}
waitForNextResult(serialNum, attempt);
}
throw new LsfxApiException("征信解析结果未返回");
}
private void waitForNextResult(String serialNum, int attempt) {
if (attempt >= RESULT_QUERY_MAX_ATTEMPTS) {
return;
}
log.info("【征信解析】结果未返回: serialNum={}, attempt={}/{}, {}ms后重试",
serialNum, attempt, RESULT_QUERY_MAX_ATTEMPTS, RESULT_QUERY_INTERVAL_MILLIS);
try {
sleepBeforeNextResultQuery(RESULT_QUERY_INTERVAL_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LsfxApiException("征信解析结果查询被中断", e);
}
}
protected void sleepBeforeNextResultQuery(long intervalMillis) throws InterruptedException {
Thread.sleep(intervalMillis);
}
private CreditParseInvokeResponse request(String url, Map<String, Object> params, String stage) {
try {
log.info("【征信解析】{}请求: url={}, params={}", stage, url, toJson(params));
String responseJson = httpUtil.postUrlEncodedFormForString(url, params, null);
log.info("【征信解析】{}返回JSON: {}", stage, responseJson);
return objectMapper.readValue(responseJson, CreditParseInvokeResponse.class);
} catch (LsfxApiException e) {
throw e;
} catch (Exception e) {
throw new LsfxApiException("征信解析" + stage + "调用失败: " + e.getMessage(), e);
}
}
private void requireSuccessfulInitiateResponse(CreditParseInvokeResponse response, String stage) {
requireSuccessfulServiceResponse(response, stage);
}
private void requireSuccessfulServiceResponse(CreditParseInvokeResponse response, String stage) {
requireSuccessfulPlatformResponse(response, stage);
if (response.getData() == null) {
throw new LsfxApiException(stage + "返回结果为空");
}
if (!Integer.valueOf(INITIATE_SUCCESS_STATUS).equals(response.getData().getStatus())) {
throw new LsfxApiException(serviceErrorMessage(response, stage + "状态异常: " + response.getData().getStatus()));
}
if (!Integer.valueOf(INITIATE_SUCCESS_REASON_CODE).equals(response.getData().getReasonCode())) {
throw new LsfxApiException(serviceErrorMessage(response, stage + "原因码异常: " + response.getData().getReasonCode()));
}
}
private void requireSuccessfulPlatformResponse(CreditParseInvokeResponse response, String stage) {
if (response == null) {
throw new LsfxApiException(stage + "返回结果为空");
}
if (!Boolean.TRUE.equals(response.getSuccess())) {
throw new LsfxApiException(stage + "平台调用失败");
}
if (!Integer.valueOf(PLATFORM_SUCCESS_CODE).equals(response.getCode())) {
throw new LsfxApiException(stage + "平台状态码异常: " + response.getCode());
}
}
private String buildSerialNum() {
return "CCDI_CREDIT_" + System.currentTimeMillis() + "_" + IdUtils.fastSimpleUUID();
}
private String stringValue(Object value, String defaultValue) {
if (value == null) {
return defaultValue;
}
String text = value.toString().trim();
return text.isEmpty() ? defaultValue : text;
}
private String serviceErrorMessage(CreditParseInvokeResponse response, String defaultValue) {
if (response == null || response.getData() == null) {
return defaultValue;
}
String reasonMessage = stringValue(response.getData().getReasonMessage(), null);
if (reasonMessage != null) {
return reasonMessage;
}
if (response.getData().getMappingOutputFields() != null) {
return stringValue(response.getData().getMappingOutputFields().getMessage(), defaultValue);
}
return defaultValue;
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception e) {
return String.valueOf(value);
}
}
}

View File

@@ -4,7 +4,7 @@ import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.lsfx.client.CreditParseClient;
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
import com.ruoyi.lsfx.exception.LsfxApiException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -14,13 +14,6 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@Tag(name = "征信解析接口测试", description = "用于测试征信解析接口")
@Anonymous
@@ -29,56 +22,27 @@ import java.nio.file.StandardCopyOption;
public class CreditParseController {
private static final String DEFAULT_MODEL = "LXCUSTALL";
private static final String DEFAULT_HTYPE = "PERSON";
@Resource
private CreditParseClient creditParseClient;
@Operation(summary = "解析征信HTML", description = "传征信HTML文件并调用外部解析服务")
@Operation(summary = "解析征信HTML", description = "征信HTML远程地址并调用外部解析服务")
@PostMapping("/parse")
public AjaxResult parse(@Parameter(description = "征信HTML文件") @RequestParam("file") MultipartFile file,
@Parameter(description = "解析模型默认LXCUSTALL") @RequestParam(required = false) String model,
@Parameter(description = "主体类型默认PERSON") @RequestParam(required = false) String hType) {
if (file == null || file.isEmpty()) {
return AjaxResult.error("征信HTML文件不能为空");
}
String originalFilename = file.getOriginalFilename();
if (StringUtils.isBlank(originalFilename)) {
return AjaxResult.error("文件名不能为空");
}
String lowerCaseName = originalFilename.toLowerCase();
if (!lowerCaseName.endsWith(".html") && !lowerCaseName.endsWith(".htm")) {
return AjaxResult.error("仅支持 HTML 格式文件");
public AjaxResult parse(@Parameter(description = "征信HTML远程访问地址") @RequestParam("remotePath") String remotePath,
@Parameter(description = "模型编码默认LXCUSTALL") @RequestParam(required = false) String model) {
if (StringUtils.isBlank(remotePath)) {
return AjaxResult.error("征信HTML远程地址不能为空");
}
String actualModel = StringUtils.isBlank(model) ? DEFAULT_MODEL : model;
String actualHType = StringUtils.isBlank(hType) ? DEFAULT_HTYPE : hType;
Path tempFile = null;
try {
String suffix = lowerCaseName.endsWith(".htm") ? ".htm" : ".html";
tempFile = Files.createTempFile("credit_parse_", suffix);
Files.copy(file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
File convertedFile = tempFile.toFile();
CreditParseResponse response = creditParseClient.parse(actualModel, actualHType, convertedFile);
CreditParseInvokeResponse response = creditParseClient.parse(actualModel, remotePath);
return AjaxResult.success(response);
} catch (LsfxApiException e) {
return AjaxResult.error(e.getMessage());
} catch (IOException e) {
return AjaxResult.error("文件转换失败:" + e.getMessage());
} catch (Exception e) {
return AjaxResult.error("征信解析失败:" + e.getMessage());
} finally {
if (tempFile != null) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
// 忽略临时文件删除失败,避免影响主流程返回
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.lsfx.domain.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CreditParseInvokeData {
private CreditParseResponse mappingOutputFields;
private Integer status;
private Integer reasonCode;
private String reasonMessage;
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.lsfx.domain.response;
import lombok.Data;
@Data
public class CreditParseInvokeResponse {
private Boolean success;
private Integer code;
private CreditParseInvokeData data;
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.lsfx.domain.response;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.DeserializationContext;
import java.io.IOException;
public class CreditParsePayloadDeserializer extends JsonDeserializer<CreditParsePayload> {
@Override
public CreditParsePayload deserialize(JsonParser parser, DeserializationContext context) throws IOException {
ObjectCodec codec = parser.getCodec();
JsonNode node = codec.readTree(parser);
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
String payloadText = node.asText();
if (payloadText == null || payloadText.trim().isEmpty()) {
return null;
}
node = codec.readTree(codec.getFactory().createParser(payloadText));
}
if (!node.isObject()) {
throw JsonMappingException.from(parser, "征信解析payload格式不支持");
}
return codec.treeToValue(node, CreditParsePayload.class);
}
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.lsfx.domain.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import lombok.Data;
@Data
@@ -11,5 +12,6 @@ public class CreditParseResponse {
@JsonProperty("status_code")
private String statusCode;
@JsonDeserialize(using = CreditParsePayloadDeserializer.class)
private CreditParsePayload payload;
}

View File

@@ -206,6 +206,86 @@ public class HttpUtil {
}
}
/**
* 发送POST请求application/x-www-form-urlencoded格式带请求头
* @param url 请求URL
* @param params 表单参数
* @param headers 请求头
* @param responseType 响应类型
* @return 响应对象
*/
public <T> T postUrlEncodedForm(String url, Map<String, Object> params, Map<String, String> headers, Class<T> responseType) {
try {
HttpHeaders httpHeaders = createHeaders(headers);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
if (params != null) {
params.forEach((key, value) -> {
if (value != null) {
body.add(key, value.toString());
}
});
}
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, httpHeaders);
ResponseEntity<T> response = restTemplate.postForEntity(url, requestEntity, responseType);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new LsfxApiException("API调用失败HTTP状态码: " + response.getStatusCode());
}
T responseBody = response.getBody();
if (responseBody == null) {
throw new LsfxApiException("API返回数据为空");
}
return responseBody;
} catch (RestClientException e) {
throw new LsfxApiException("网络请求失败: " + e.getMessage(), e);
}
}
/**
* 发送POST请求application/x-www-form-urlencoded格式并返回原始JSON字符串
* @param url 请求URL
* @param params 表单参数
* @param headers 请求头
* @return 原始响应内容
*/
public String postUrlEncodedFormForString(String url, Map<String, Object> params, Map<String, String> headers) {
try {
HttpHeaders httpHeaders = createHeaders(headers);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
if (params != null) {
params.forEach((key, value) -> {
if (value != null) {
body.add(key, value.toString());
}
});
}
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, httpHeaders);
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new LsfxApiException("API调用失败HTTP状态码: " + response.getStatusCode());
}
String responseBody = response.getBody();
if (responseBody == null) {
throw new LsfxApiException("API返回数据为空");
}
return responseBody;
} catch (RestClientException e) {
throw new LsfxApiException("网络请求失败: " + e.getMessage(), e);
}
}
/**
* 上传文件Multipart格式
* @param url 请求URL

View File

@@ -1,24 +1,34 @@
package com.ruoyi.lsfx.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.lsfx.client.CreditParseClient;
import com.ruoyi.lsfx.domain.response.CreditParseResponse;
import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse;
import com.ruoyi.lsfx.exception.LsfxApiException;
import com.ruoyi.lsfx.util.HttpUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -31,31 +41,21 @@ class CreditParseControllerTest {
private CreditParseController controller;
@Test
void parse_shouldRejectEmptyFile() {
AjaxResult result = controller.parse(null, null, null);
void parse_shouldRejectBlankRemotePath() {
AjaxResult result = controller.parse(null, null);
assertEquals(500, result.get("code"));
}
@Test
void parse_shouldRejectNonHtmlFile() {
MockMultipartFile file = new MockMultipartFile(
"file", "credit.pdf", "application/pdf", "x".getBytes(StandardCharsets.UTF_8)
);
AjaxResult result = controller.parse(file, null, null);
assertEquals(500, result.get("code"));
}
void shouldUseDefaultModelWhenMissing() {
CreditParseInvokeResponse response = new CreditParseInvokeResponse();
response.setSuccess(true);
response.setCode(10000);
@Test
void shouldUseDefaultModelAndTypeWhenMissing() {
MockMultipartFile file = new MockMultipartFile(
"file", "credit.html", "text/html", "<html/>".getBytes(StandardCharsets.UTF_8)
);
CreditParseResponse response = new CreditParseResponse();
response.setStatusCode("0");
String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html";
when(client.parse(eq("LXCUSTALL"), eq(remotePath))).thenReturn(response);
when(client.parse(eq("LXCUSTALL"), eq("PERSON"), any(File.class))).thenReturn(response);
AjaxResult result = controller.parse(file, null, null);
AjaxResult result = controller.parse(remotePath, null);
assertEquals(200, result.get("code"));
assertSame(response, result.get("data"));
@@ -63,14 +63,230 @@ class CreditParseControllerTest {
@Test
void shouldReturnAjaxErrorWhenClientThrows() {
MockMultipartFile file = new MockMultipartFile(
"file", "credit.html", "text/html", "<html/>".getBytes(StandardCharsets.UTF_8)
);
when(client.parse(anyString(), anyString(), any(File.class)))
when(client.parse(anyString(), anyString()))
.thenThrow(new LsfxApiException("超时"));
AjaxResult result = controller.parse(file, null, null);
AjaxResult result = controller.parse("http://127.0.0.1:62318/profile/credit-html/a.html", null);
assertEquals(500, result.get("code"));
}
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
void creditParseClient_shouldInitiateAndQueryResultWithSameSerialNum() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
CreditParseClient parseClient = new CreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
String payload = "{\"lx_header\":{\"query_cert_no\":\"330101199001010011\",\"query_cust_name\":\"张三\",\"report_time\":\"2026-03-24\"},\"lx_debt\":{\"uncle_bank_house_bal\":\"1\"},\"lx_publictype\":{\"civil_cnt\":1}}";
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE"));
String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html";
CreditParseInvokeResponse actual = parseClient.parse(remotePath);
assertEquals(true, actual.getSuccess());
assertEquals(10000, actual.getCode());
assertEquals("330101199001010011", actual.getData().getMappingOutputFields()
.getPayload().getLxHeader().get("query_cert_no"));
ArgumentCaptor<Map<String, Object>> initiateParamsCaptor = ArgumentCaptor.forClass((Class) Map.class);
verify(httpUtil).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
initiateParamsCaptor.capture(),
isNull()
);
ArgumentCaptor<Map<String, Object>> resultParamsCaptor = ArgumentCaptor.forClass((Class) Map.class);
verify(httpUtil).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
resultParamsCaptor.capture(),
isNull()
);
Map<String, Object> initiateParams = initiateParamsCaptor.getValue();
Map<String, Object> resultParams = resultParamsCaptor.getValue();
assertNotNull(initiateParams.get("serialNum"));
assertTrue(initiateParams.get("serialNum").toString().startsWith("CCDI_CREDIT_"));
assertEquals(initiateParams.get("serialNum"), resultParams.get("serialNum"));
assertEquals("999000", initiateParams.get("orgCode"));
assertEquals("999000", resultParams.get("orgCode"));
assertEquals("1", initiateParams.get("runType"));
assertEquals("1", resultParams.get("runType"));
assertEquals(remotePath, initiateParams.get("remotePath"));
assertEquals("LXCUSTALL", initiateParams.get("model"));
assertEquals(false, resultParams.containsKey("remotePath"));
assertEquals(false, resultParams.containsKey("model"));
}
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
void creditParseClient_shouldRetryEmptyPayloadFiveTimesWithTwoSecondInterval() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
String emptyPayloadResponse = "{\"success\":true,\"code\":10000,\"data\":{\"status\":1,\"reasonCode\":200,\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"ERR_SHOULD_IGNORE\"}}}";
String payload = "{\"lx_header\":{\"query_cert_no\":\"330101199001010011\",\"query_cust_name\":\"张三\",\"report_time\":\"2026-03-24\"}}";
String resultResponse = resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE");
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, resultResponse);
CreditParseInvokeResponse actual = parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html");
assertEquals("330101199001010011", actual.getData().getMappingOutputFields()
.getPayload().getLxHeader().get("query_cert_no"));
verify(httpUtil, times(5)).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
);
assertEquals(List.of(2000L, 2000L, 2000L, 2000L), parseClient.getSleepIntervals());
}
@Test
void creditParseClient_shouldRejectInvalidOuterCode() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn("{\"success\":true,\"code\":99999,\"data\":{\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"0\"}}}");
LsfxApiException exception = assertThrows(LsfxApiException.class,
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
assertTrue(exception.getMessage().contains("平台状态码异常"));
}
@Test
void creditParseClient_shouldRejectFailedResultStatus() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn(initiateSuccessResponse());
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"reasonMessage\":\"解析失败\",\"reasonCode\":500,\"status\":0,\"mappingOutputFields\":{\"message\":\"结果异常\"}}}");
LsfxApiException exception = assertThrows(LsfxApiException.class,
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
assertTrue(exception.getMessage().contains("解析失败"));
}
@Test
void creditParseClient_shouldRejectFailedInitiateStatus() throws Exception {
HttpUtil httpUtil = mock(HttpUtil.class);
TestableCreditParseClient parseClient = new TestableCreditParseClient();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(parseClient, "httpUtil", httpUtil);
ReflectionTestUtils.setField(parseClient, "creditParseUrl", "http://tz/api/service/interface/invokeService/xfeature");
ReflectionTestUtils.setField(parseClient, "creditParseResultUrl", "http://tz/api/service/interface/invokeService/xfeatureResult");
ReflectionTestUtils.setField(parseClient, "orgCode", "999000");
ReflectionTestUtils.setField(parseClient, "runType", "1");
ReflectionTestUtils.setField(parseClient, "defaultModel", "LXCUSTALL");
ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper);
when(httpUtil.postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeature"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
)).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"mappingOutputFields\":{\"message\":\"文件写入失败\"},\"reasonMessage\":\"文件写入失败\",\"reasonCode\":500,\"status\":0}}");
LsfxApiException exception = assertThrows(LsfxApiException.class,
() -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"));
assertTrue(exception.getMessage().contains("文件写入失败"));
verify(httpUtil, times(0)).postUrlEncodedFormForString(
eq("http://tz/api/service/interface/invokeService/xfeatureResult"),
org.mockito.ArgumentMatchers.<Map<String, Object>>any(),
isNull()
);
}
private static String initiateSuccessResponse() {
return "{\"success\":true,\"code\":10000,\"data\":{\"traceId\":\"TRACE-001\","
+ "\"mappingOutputFields\":{\"message\":\"文件写入成功,流水号为: CCDI_CREDIT_TEST\"},"
+ "\"reasonMessage\":\"Running successfully\",\"procCode\":\"999000\","
+ "\"reasonCode\":200,\"status\":1}}";
}
private static String resultSuccessResponse(ObjectMapper objectMapper, String payload, String statusCode) throws Exception {
return "{\"success\":true,\"code\":10000,\"data\":{\"status\":1,\"reasonCode\":200,"
+ "\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"" + statusCode + "\",\"payload\":"
+ objectMapper.writeValueAsString(payload) + "}}}";
}
private static class TestableCreditParseClient extends CreditParseClient {
private final List<Long> sleepIntervals = new ArrayList<>();
@Override
protected void sleepBeforeNextResultQuery(long intervalMillis) {
sleepIntervals.add(intervalMillis);
}
private List<Long> getSleepIntervals() {
return sleepIntervals;
}
}
}

View File

@@ -9,6 +9,7 @@ public final class CcdiProjectStatusConstants {
public static final String COMPLETED = "1";
public static final String ARCHIVED = "2";
public static final String TAGGING = "3";
public static final String TAG_FAILED = "4";
private CcdiProjectStatusConstants() {
}

View File

@@ -71,9 +71,9 @@ public class CcdiFileUploadController extends BaseController {
return AjaxResult.error("文件名不能为空");
}
String lowerFileName = fileName.toLowerCase();
if (!lowerFileName.endsWith(".xlsx") && !lowerFileName.endsWith(".xls")
&& !lowerFileName.endsWith(".csv") && !lowerFileName.endsWith(".pdf")) {
return AjaxResult.error("文件 " + fileName + " 格式不支持, 仅支持 PDF, CSV, Excel 文件");
if (!lowerFileName.endsWith(".xlsx") && !lowerFileName.endsWith(".csv")
&& !lowerFileName.endsWith(".pdf")) {
return AjaxResult.error("文件 " + fileName + " 格式不支持, 仅支持 PDF, CSV, XLSX 文件");
}
}

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

@@ -184,10 +184,10 @@ public class CcdiProjectOverviewController extends BaseController {
}
/**
* 一键导出结果总览报告
* 导出结果总览报告
*/
@RequestMapping(value = "/report/export", method = { RequestMethod.GET, RequestMethod.POST })
@Operation(summary = "一键导出结果总览报告")
@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

@@ -32,7 +32,7 @@ public class CcdiProject implements Serializable {
/** 配置方式default-全局默认custom-自定义 */
private String configType;
/** 项目状态0-进行中1-已完成2-已归档3-打标中 */
/** 项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败 */
private String status;
/** 是否归档0-未归档1-已归档 */

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

@@ -23,4 +23,7 @@ public class CcdiProjectStatusCountsVO {
/** 打标中项目数(状态3) */
private Long status3;
/** 打标失败项目数(状态4) */
private Long status4;
}

View File

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

View File

@@ -50,6 +50,12 @@ public class CcdiProjectVO {
/** 更新时间 */
private Date updateTime;
/** 最近一次打标失败原因 */
private String latestTagTaskErrorMessage;
/** 最近一次打标失败结束时间 */
private Date latestTagTaskEndTime;
/** 创建者(用户名) */
private String createBy;

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 amountThreshold 提现金额阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId);
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId,
@Param("amountThreshold") BigDecimal amountThreshold);
/**
* 工资快速转出

View File

@@ -32,4 +32,12 @@ public interface CcdiBankTagTaskMapper extends BaseMapper<CcdiBankTagTask> {
* @return 任务实体
*/
CcdiBankTagTask selectRunningTaskByProjectId(@Param("projectId") Long projectId);
/**
* 查询项目最近一次失败任务
*
* @param projectId 项目ID
* @return 任务实体
*/
CcdiBankTagTask selectLatestFailedTaskByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -0,0 +1,49 @@
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<CcdiFundGraphNodeVO> selectFundGraphSubjectsByExactKeyword(@Param("query") CcdiFundGraphQueryDTO query);
List<CcdiFundGraphNodeVO> selectFundGraphSubjectsByName(@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

@@ -36,9 +36,10 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
List<CcdiProjectHistoryListItemVO> selectHistoryProjects(@Param("queryDTO") CcdiProjectQueryDTO queryDTO);
/**
* 更新项目风险人数
* 更新项目总人数与风险人数
*
* @param projectId 项目ID
* @param targetCount 总人数
* @param highRiskCount 高风险人数
* @param mediumRiskCount 中风险人数
* @param lowRiskCount 低风险人数
@@ -46,6 +47,7 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
* @return 更新行数
*/
int updateRiskCountsByProjectId(@Param("projectId") Long projectId,
@Param("targetCount") Integer targetCount,
@Param("highRiskCount") Integer highRiskCount,
@Param("mediumRiskCount") Integer mediumRiskCount,
@Param("lowRiskCount") Integer lowRiskCount,

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

@@ -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_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
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("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")),
Map.entry("MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),

View File

@@ -153,7 +153,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
updateFailedTaskSafely(task, ex);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.PROCESSING, operator);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.TAG_FAILED, operator);
log.error("【流水标签】任务执行失败: taskId={}, projectId={}, modelCode={}, triggerType={}, error={}",
task.getId(), projectId, modelCode, triggerType, ex.getMessage(), ex);
throw ex;
@@ -285,7 +285,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(
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_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);
case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId);

View File

@@ -226,15 +226,18 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
CcdiFileUploadRecord record = recordMapper.selectById(id);
validateDeleteRecord(record);
DeleteFilesRequest request = new DeleteFilesRequest();
request.setGroupId(record.getLsfxProjectId());
request.setLogIds(new Integer[]{record.getLogId()});
request.setUserId(toUploadUserId(operatorUserId));
DeleteFilesResponse response = lsfxClient.deleteFiles(request);
if (response == null || Boolean.FALSE.equals(response.getSuccessResponse())) {
throw new RuntimeException("流水分析平台删除文件失败");
}
/*
* 按当前要求,项目管理-上传数据页面删除时先不调用流水分析平台删除接口。
* DeleteFilesRequest request = new DeleteFilesRequest();
* request.setGroupId(record.getLsfxProjectId());
* request.setLogIds(new Integer[]{record.getLogId()});
* request.setUserId(toUploadUserId(operatorUserId));
*
* DeleteFilesResponse response = lsfxClient.deleteFiles(request);
* if (response == null || Boolean.FALSE.equals(response.getSuccessResponse())) {
* throw new RuntimeException("流水分析平台删除文件失败");
* }
*/
bankStatementMapper.deleteByProjectIdAndBatchId(record.getProjectId(), record.getLogId());
refreshProjectTargetCount(record.getProjectId());

View File

@@ -0,0 +1,383 @@
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 selectSubjects(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 = selectSubjects(query);
if (subjects == null || subjects.isEmpty()) {
return null;
}
return subjects.get(0);
}
private List<CcdiFundGraphNodeVO> selectSubjects(CcdiFundGraphQueryDTO query) {
if (query == null) {
return Collections.emptyList();
}
if (!isBlank(query.getObjectKey())) {
return fundGraphMapper.selectFundGraphSubjects(query);
}
if (isBlank(query.getKeyword())) {
return Collections.emptyList();
}
List<CcdiFundGraphNodeVO> exactSubjects = fundGraphMapper.selectFundGraphSubjectsByExactKeyword(query);
if (exactSubjects != null && !exactSubjects.isEmpty()) {
return exactSubjects;
}
return fundGraphMapper.selectFundGraphSubjectsByName(query);
}
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -53,6 +54,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
private ICcdiProjectService projectService;
@Resource
@Lazy
private ICcdiBankTagService bankTagService;
@Override

View File

@@ -469,6 +469,7 @@ public class CcdiProjectOverviewReportPdfExporter {
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;
@@ -496,7 +497,7 @@ public class CcdiProjectOverviewReportPdfExporter {
}
void title(String text) throws IOException {
writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F);
writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F, true);
}
void text(String text, float fontSize, Color color) throws IOException {
@@ -505,12 +506,12 @@ public class CcdiProjectOverviewReportPdfExporter {
void section(String text) throws IOException {
ensureSpace(32F);
writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F);
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);
writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F, true);
}
void separator() throws IOException {
@@ -557,7 +558,7 @@ public class CcdiProjectOverviewReportPdfExporter {
for (List<String> row : safeRows) {
drawRow(row, widths, false);
}
y -= 8F;
y -= TABLE_AFTER_GAP;
}
private float[] calculateWidths(float[] ratios) {
@@ -632,6 +633,17 @@ public class CcdiProjectOverviewReportPdfExporter {
}
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);
@@ -639,6 +651,14 @@ public class CcdiProjectOverviewReportPdfExporter {
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;
}

View File

@@ -1,7 +1,6 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
@@ -40,11 +39,13 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.exception.ServiceException;
import jakarta.servlet.http.HttpServletResponse;
@@ -57,6 +58,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -75,7 +77,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
private CcdiProjectMapper projectMapper;
@Resource
private CcdiModelParamMapper modelParamMapper;
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
@@ -92,6 +94,10 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource
private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
@Resource
@Lazy
private ICcdiModelParamService modelParamService;
@Override
public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) {
CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId);
@@ -328,7 +334,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
report.setUploadSubjects(defaultList(overviewMapper.selectReportUploadSubjects(projectId)).stream()
.peek(item -> item.setDataPeriod(formatDataPeriod(item.getMinTrxDate(), item.getMaxTrxDate())))
.toList());
report.setParams(buildReportParams(project));
report.setParams(buildReportParams(projectId));
report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId)));
report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream()
.peek(item -> item.setActionLabel(ACTION_LABEL))
@@ -371,6 +377,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
projectMapper.updateRiskCountsByProjectId(
projectId,
countProjectScopeStaff(projectId),
countRiskLevel(results, "HIGH"),
countRiskLevel(results, "MEDIUM"),
countRiskLevel(results, "LOW"),
@@ -385,6 +392,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
Map<String, Object> summary = overviewMapper.selectRiskCountSummaryByProjectId(projectId);
projectMapper.updateRiskCountsByProjectId(
projectId,
countProjectScopeStaff(projectId),
readCount(summary, "highRiskCount"),
readCount(summary, "mediumRiskCount"),
readCount(summary, "lowRiskCount"),
@@ -392,6 +400,10 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
);
}
private int countProjectScopeStaff(Long projectId) {
return defaultZero(bankStatementMapper.countMatchedStaffCountByProjectId(projectId));
}
private CcdiProjectRiskPeopleOverviewItemVO buildRiskPeopleItem(Long projectId, CcdiProjectEmployeeRiskAggregateVO aggregate) {
CcdiProjectRiskPeopleOverviewItemVO item = new CcdiProjectRiskPeopleOverviewItemVO();
item.setName(aggregate.getStaffName());
@@ -554,23 +566,21 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row;
}
private List<CcdiProjectOverviewReportParamVO> buildReportParams(CcdiProject project) {
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : project.getProjectId();
return defaultList(modelParamMapper.selectByProjectId(effectiveProjectId)).stream()
.map(this::buildReportParamRow)
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 CcdiProjectOverviewReportParamVO buildReportParamRow(CcdiModelParam param) {
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName(param.getModelName());
row.setParamName(param.getParamName());
row.setParamValue(param.getParamValue());
row.setParamUnit(param.getParamUnit());
row.setParamDesc(param.getParamDesc());
return row;
}
private String formatDataPeriod(String minTrxDate, String maxTrxDate) {
if (minTrxDate == null || minTrxDate.isBlank() || maxTrxDate == null || maxTrxDate.isBlank()) {
return "-";

View File

@@ -7,10 +7,12 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.event.CcdiProjectHistoryImportSubmittedEvent;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
@@ -43,6 +45,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private CcdiBankTagTaskMapper bankTagTaskMapper;
@Resource
private LsfxAnalysisClient lsfxAnalysisClient;
@@ -77,6 +82,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
// 5. 返回VO
CcdiProjectVO vo = new CcdiProjectVO();
BeanUtils.copyProperties(project, vo);
fillLatestTagFailure(project, vo);
return vo;
}
@@ -116,6 +122,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
}
CcdiProjectVO vo = new CcdiProjectVO();
BeanUtils.copyProperties(project, vo);
fillLatestTagFailure(project, vo);
return vo;
}
@@ -183,6 +190,12 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
);
vo.setStatus3(status3Count);
Long status4Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAG_FAILED)
);
vo.setStatus4(status4Count);
return vo;
}
@@ -263,10 +276,23 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
case CcdiProjectStatusConstants.COMPLETED -> "已完成";
case CcdiProjectStatusConstants.ARCHIVED -> "已归档";
case CcdiProjectStatusConstants.TAGGING -> "打标中";
case CcdiProjectStatusConstants.TAG_FAILED -> "打标失败";
default -> "未知";
};
}
private void fillLatestTagFailure(CcdiProject project, CcdiProjectVO vo) {
if (!CcdiProjectStatusConstants.TAG_FAILED.equals(project.getStatus())) {
return;
}
CcdiBankTagTask latestFailedTask = bankTagTaskMapper.selectLatestFailedTaskByProjectId(project.getProjectId());
if (latestFailedTask == null) {
return;
}
vo.setLatestTagTaskErrorMessage(latestFailedTask.getErrorMessage());
vo.setLatestTagTaskEndTime(latestFailedTask.getEndTime());
}
private String resolveOperator(String operator) {
return StringUtils.hasText(operator) ? operator : "system";
}

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

@@ -119,7 +119,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="countMatchedStaffCountByProjectId" resultType="java.lang.Integer">
select count(distinct trim(bs.cret_no))
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 = trim(bs.cret_no)
where bs.project_id = #{projectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''

View File

@@ -105,36 +105,123 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="salaryExclusionPredicate">
not (
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
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 '%SALA%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
(
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
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 '%SALA%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
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.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 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">
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
@@ -167,6 +254,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
)
and <include refid="financialProductExclusionPredicate"/>
and (
exists (
select 1
@@ -198,6 +286,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政'
)
and <include refid="financialProductExclusionPredicate"/>
and (
exists (
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 relation.person_id is null
and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
</select>
<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 relation.person_id is null
and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) 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
where bs.project_id = #{projectId}
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)
group by staff.id_card
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}
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
and <include refid="cashDepositPredicate"/>
and (
exists (
select 1
from ccdi_base_staff staff
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
)
and <include refid="financialProductExclusionPredicate"/>
and exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
</select>
@@ -340,16 +425,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/>
union all
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"/>
and <include refid="financialProductExclusionPredicate"/>
) source
group by source.object_key, source.cash_date
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.CASH_TYPE, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
)
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%款%'
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and (
exists (
select 1
@@ -392,12 +468,130 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
hit.bankStatementId AS bankStatementId,
max(hit.groupId) AS groupId,
max(hit.logId) AS logId,
substring_index(
min(concat(lpad(hit.matchPriority, 2, '0'), '|', hit.reasonDetail)),
'|',
-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 id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap">
@@ -416,9 +610,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_staff_fmy_relation relation
inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no
where relation.status = 1
and relation.annual_income is not null
and (
relation.annual_income is null
or relation.annual_income = 0
relation.annual_income = 0
or relation.annual_income / 12 &lt; 3000
)
and bs.project_id = #{projectId}
@@ -503,8 +697,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注'
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
)
</select>
@@ -658,6 +852,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0
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 (
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
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.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
)
and <include refid="financialProductExclusionPredicate"/>
</select>
<select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap">
@@ -1074,11 +1278,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
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 (
IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
)
and IFNULL(bs.AMOUNT_CR, 0) > 0
and <include refid="thirdPartyWithdrawIncomePredicate"/>
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having COUNT(1) > #{frequencyThreshold}
) t
@@ -1087,10 +1288,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
t.objectKey AS objectKey,
CONCAT(
'单日微信/支付宝提现到账金额 ', CAST(t.withdrawAmount AS CHAR),
' 元,超过阈值 ', 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 id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap">
@@ -1322,10 +1538,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
)
and <include refid="financialProductExclusionPredicate"/>
</select>
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">

View File

@@ -65,4 +65,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
limit 1
</select>
<select id="selectLatestFailedTaskByProjectId" resultMap="CcdiBankTagTaskResultMap">
select id, project_id, trigger_type, model_code, status, need_rerun, success_rule_count,
failed_rule_count, hit_count, error_message, start_time, end_time,
create_by, create_time, update_by, update_time
from ccdi_bank_tag_task
where project_id = #{projectId}
and status = 'FAILED'
order by id desc
limit 1
</select>
</mapper>

View File

@@ -0,0 +1,475 @@
<?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
<include refid="detailFilter"/>
</sql>
<sql id="subjectJoinRowsByCenter">
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,
center_subject.object_key AS fromObjectKey,
to_subject.object_key AS toObjectKey,
CONCAT('idno_node/', center_subject.object_key) AS fromKey,
CONCAT('idno_node/', to_subject.object_key) AS toKey,
center_subject.name AS fromName,
to_subject.name AS toName,
center_subject.idnocfno AS fromIdNo,
to_subject.idnocfno AS toIdNo
FROM lx_fund_flow_subject_node center_subject
INNER JOIN lx_fund_flow_own_account_edge from_own
ON from_own.from_key = CONCAT('idno_node/', center_subject.object_key)
INNER JOIN lx_fund_flow_detail_edge d
ON d.from_key = from_own.to_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 to_subject.object_key = SUBSTRING(to_own.from_key, 11)
WHERE center_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
<include refid="detailFilter"/>
UNION ALL
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,
center_subject.object_key AS toObjectKey,
CONCAT('idno_node/', from_subject.object_key) AS fromKey,
CONCAT('idno_node/', center_subject.object_key) AS toKey,
from_subject.name AS fromName,
center_subject.name AS toName,
from_subject.idnocfno AS fromIdNo,
center_subject.idnocfno AS toIdNo
FROM lx_fund_flow_subject_node center_subject
INNER JOIN lx_fund_flow_own_account_edge to_own
ON to_own.from_key = CONCAT('idno_node/', center_subject.object_key)
INNER JOIN lx_fund_flow_detail_edge d
ON d.to_key = to_own.to_key
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 from_subject.object_key = SUBSTRING(from_own.from_key, 11)
WHERE center_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
AND from_subject.object_key != center_subject.object_key
<include refid="detailFilter"/>
</sql>
<sql id="fundGraphSubjectColumns">
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
</sql>
<select id="selectFundGraphSubjects" resultMap="FundGraphNodeResultMap">
SELECT
<include refid="fundGraphSubjectColumns"/>
FROM lx_fund_flow_subject_node n
WHERE n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
</select>
<select id="selectFundGraphSubjectsByExactKeyword" resultMap="FundGraphNodeResultMap">
SELECT exact_rows.*
FROM (
SELECT
<include refid="fundGraphSubjectColumns"/>,
0 AS matchOrder
FROM lx_fund_flow_subject_node n
WHERE n.idnocfno = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
UNION ALL
SELECT
<include refid="fundGraphSubjectColumns"/>,
1 AS matchOrder
FROM lx_fund_flow_subject_node n
WHERE n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
AND (
n.idnocfno IS NULL
OR n.idnocfno != (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
)
) exact_rows
ORDER BY exact_rows.matchOrder, exact_rows.nodeName
LIMIT
<choose>
<when test="query.limit != null and query.limit > 0">
#{query.limit}
</when>
<otherwise>
20
</otherwise>
</choose>
</select>
<select id="selectFundGraphSubjectsByName" resultMap="FundGraphNodeResultMap">
SELECT
<include refid="fundGraphSubjectColumns"/>
FROM lx_fund_flow_subject_node n
WHERE n.name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
ORDER BY
CASE
WHEN n.staff_id IS NOT NULL AND TRIM(n.staff_id) != '' THEN 0
WHEN UPPER(IFNULL(n.source_type, '')) LIKE '%EMPLOYEE%' THEN 0
WHEN n.source_type LIKE '%员工%' THEN 0
ELSE 1
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="subjectJoinRowsByCenter"/>
) 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

@@ -70,7 +70,8 @@
<update id="updateRiskCountsByProjectId">
update ccdi_project
set high_risk_count = #{highRiskCount},
set target_count = #{targetCount},
high_risk_count = #{highRiskCount},
medium_risk_count = #{mediumRiskCount},
low_risk_count = #{lowRiskCount},
update_by = #{updateBy},

View File

@@ -46,6 +46,7 @@
<result property="displayAmount" column="displayAmount"/>
<result property="hasModelRuleHit" column="hasModelRuleHit"/>
<result property="hasNameListHit" column="hasNameListHit"/>
<result property="nameListHitType" column="nameListHitType"/>
</resultMap>
<resultMap id="AbnormalAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO">
@@ -530,57 +531,70 @@
from ccdi_bank_statement_tag_result tr
where tr.project_id = #{query.projectId}
and tr.bank_statement_id is not null
and tr.rule_name like '%可疑%'
</sql>
<sql id="suspiciousTransactionNameHitSql">
select
hits.bankStatementId,
hits.suspiciousPersonName,
hits.matchPriority
hits.matchPriority,
hits.nameListHitType
from (
select
bs.bank_statement_id as bankStatementId,
intermediary.name as suspiciousPersonName,
1 as matchPriority
coalesce(credit_customer.name, account.account_name, '信贷客户账号') as suspiciousPersonName,
1 as matchPriority,
'信贷客户' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_biz_intermediary intermediary
on trim(bs.customer_cert_no) != ''
and intermediary.person_id = bs.customer_cert_no
inner join ccdi_account_info account
on trim(bs.customer_account_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}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id as bankStatementId,
enterprise.enterprise_name as suspiciousPersonName,
2 as matchPriority
coalesce(intermediary.name, enterprise.enterprise_name, account.account_name, '中介账号') as suspiciousPersonName,
2 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_enterprise_base_info enterprise
on trim(bs.customer_social_credit_code) != ''
and enterprise.social_credit_code = bs.customer_social_credit_code
and enterprise.risk_level = '1'
and enterprise.ent_source = 'INTERMEDIARY'
inner join ccdi_account_info account
on trim(bs.customer_account_no) != ''
and account.owner_type = 'INTERMEDIARY'
and account.account_no = bs.customer_account_no
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}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id as bankStatementId,
intermediary.name as suspiciousPersonName,
3 as matchPriority
3 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_biz_intermediary intermediary
on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id as bankStatementId,
enterprise.enterprise_name as suspiciousPersonName,
3 as matchPriority
4 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_enterprise_base_info enterprise
on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
@@ -588,6 +602,7 @@
and enterprise.risk_level = '1'
and enterprise.ent_source = 'INTERMEDIARY'
where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
) hits
</sql>
@@ -605,7 +620,8 @@
1 as hasModelRuleHit,
0 as hasNameListHit,
null as suspiciousPersonName,
null as matchPriority
null as matchPriority,
null as nameListHitType
from (
<include refid="suspiciousTransactionBaseSql"/>
) base
@@ -628,7 +644,8 @@
0 as hasModelRuleHit,
1 as hasNameListHit,
name_hits.suspiciousPersonName,
name_hits.matchPriority
name_hits.matchPriority,
name_hits.nameListHitType
from (
<include refid="suspiciousTransactionBaseSql"/>
) base
@@ -663,7 +680,18 @@
max(merged.cashType) as cashType,
max(merged.displayAmount) as displayAmount,
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 (
<include refid="suspiciousTransactionMergedSql"/>
) merged
@@ -685,7 +713,7 @@
</sql>
<select id="selectSuspiciousTransactionPage" resultMap="SuspiciousTransactionItemResultMap">
<!-- rule_name like '%可疑%' -->
<!-- ccdi_bank_statement_tag_result -->
<!-- ccdi_biz_intermediary -->
<!-- ccdi_enterprise_base_info -->
<!-- group by merged.bankStatementId -->
@@ -701,7 +729,8 @@
final_result.cashType,
final_result.displayAmount,
final_result.hasModelRuleHit,
final_result.hasNameListHit
final_result.hasNameListHit,
final_result.nameListHitType
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
@@ -722,7 +751,8 @@
final_result.cashType,
final_result.displayAmount,
final_result.hasModelRuleHit,
final_result.hasNameListHit
final_result.hasNameListHit,
final_result.nameListHitType
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
@@ -742,7 +772,21 @@
final_result.relatedStaffCode,
final_result.userMemo,
final_result.cashType,
tag_result.hitTags,
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"/>

View File

@@ -82,7 +82,7 @@
<result property="spouseTotalAsset" column="asset_spouse_total_asset"/>
<result property="totalAsset" column="asset_total_asset"/>
<collection property="items"
column="{projectId=project_id,staffIdCard=staff_id_card,spouseIdCard=spouse_id_card}"
column="{projectId=project_id,staffIdCard=staff_id_card,spouseIdCard=spouse_id_card,spouseIsStaff=spouse_is_staff}"
ofType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetItemVO"
select="selectFamilyAssetItemsByScope"/>
</association>
@@ -93,7 +93,7 @@
<result property="spouseTotalDebt" column="debt_spouse_total_debt"/>
<result property="totalDebt" column="debt_total_debt"/>
<collection property="items"
column="{projectId=project_id,staffIdCard=staff_id_card,spouseIdCard=spouse_id_card}"
column="{projectId=project_id,staffIdCard=staff_id_card,spouseIdCard=spouse_id_card,spouseIsStaff=spouse_is_staff}"
ofType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyDebtItemVO"
select="selectFamilyDebtItemsByScope"/>
</association>
@@ -144,15 +144,39 @@
<sql id="spouseRelationSql">
select
person_id,
max(relation_name) as spouse_name,
min(relation_cert_no) as spouse_id_card,
max(annual_income) as spouse_income
from ccdi_staff_fmy_relation
where status = 1
and is_emp_family = 1
and relation_type = '配偶'
group by person_id
relation_pair.person_id,
max(relation_pair.spouse_name) as spouse_name,
min(relation_pair.spouse_id_card) as spouse_id_card,
max(coalesce(spouse_staff.annual_income, relation_pair.spouse_relation_income, 0)) as spouse_income,
max(case when spouse_staff.id_card is not null then 1 else 0 end) as spouse_is_staff
from (
select
relation.person_id,
relation.relation_name as spouse_name,
relation.relation_cert_no as spouse_id_card,
relation.annual_income as spouse_relation_income
from ccdi_staff_fmy_relation relation
where relation.status = 1
and relation.is_emp_family = 1
and relation.relation_type = '配偶'
union all
select
relation.relation_cert_no as person_id,
base_staff.name as spouse_name,
relation.person_id as spouse_id_card,
null as spouse_relation_income
from ccdi_staff_fmy_relation relation
inner join ccdi_base_staff current_staff
on current_staff.id_card = relation.relation_cert_no
left join ccdi_base_staff base_staff
on base_staff.id_card = relation.person_id
where relation.status = 1
and relation.is_emp_family = 1
and relation.relation_type = '配偶'
) relation_pair
left join ccdi_base_staff spouse_staff
on spouse_staff.id_card = relation_pair.spouse_id_card
group by relation_pair.person_id
</sql>
<select id="selectFamilyAssetLiabilityList" resultMap="FamilyAssetLiabilityListItemResultMap">
@@ -166,14 +190,14 @@
aggregated.total_debt,
aggregated.comparison_amount,
case
when aggregated.self_asset_record_count = 0 or aggregated.self_debt_record_count = 0 then 'MISSING_INFO'
when aggregated.missing_asset_info = 1 or aggregated.missing_debt_info = 1 then 'MISSING_INFO'
when comparison_amount &lt;= total_asset * 1.5 then 'NORMAL'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then 'RISK'
when comparison_amount &gt; total_asset * 3 then 'HIGH'
else 'HIGH'
end as risk_level_code,
case
when aggregated.self_asset_record_count = 0 or aggregated.self_debt_record_count = 0 then '缺少信息'
when aggregated.missing_asset_info = 1 or aggregated.missing_debt_info = 1 then '缺少信息'
when comparison_amount &lt;= total_asset * 1.5 then '正常'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then '存在风险'
when comparison_amount &gt; total_asset * 3 then '高风险'
@@ -181,122 +205,106 @@
end as risk_level_name
from (
select
scope.staff_id_card,
scope.staff_code,
scope.staff_name,
scope.dept_name,
coalesce(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
coalesce((
select count(1)
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and asset.person_id = scope.staff_id_card
), 0) as self_asset_record_count,
coalesce((
select count(1)
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
), 0) as self_debt_record_count,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and (
asset.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and asset.person_id = spouse.spouse_id_card)
)
), 0) as total_asset,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as total_debt,
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as comparison_amount,
source.*,
case
when coalesce((
when source.self_asset_record_count = 0
or source.spouse_staff_asset_record_count = 0 then 1
else 0
end as missing_asset_info,
case
when source.self_debt_record_count = 0
or source.spouse_staff_debt_record_count = 0 then 1
else 0
end as missing_debt_info,
case
when source.self_asset_record_count = 0
or source.spouse_staff_asset_record_count = 0
or source.self_debt_record_count = 0
or source.spouse_staff_debt_record_count = 0 then 4
when source.comparison_amount &lt;= source.total_asset * 1.5 then 1
when source.comparison_amount &lt;= source.total_asset * 3 then 2
when source.comparison_amount &gt; source.total_asset * 3 then 3
else 3
end as risk_level_sort
from (
select
scope.staff_id_card,
scope.staff_code,
scope.staff_name,
scope.dept_name,
coalesce(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
coalesce((
select count(1)
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and asset.person_id = scope.staff_id_card
), 0) = 0
or coalesce((
), 0) as self_asset_record_count,
case
when spouse.spouse_is_staff = 1 then coalesce((
select count(1)
from ccdi_asset_info asset
where asset.family_id = spouse.spouse_id_card
and asset.person_id = spouse.spouse_id_card
), 0)
else 1
end as spouse_staff_asset_record_count,
coalesce((
select count(1)
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
), 0) as self_debt_record_count,
case
when spouse.spouse_is_staff = 1 then coalesce((
select count(1)
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
), 0) = 0 then 4
when (
coalesce(base_staff.annual_income, 0)
where debt.person_id = spouse.spouse_id_card
), 0)
else 1
end as spouse_staff_debt_record_count,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where (asset.family_id = scope.staff_id_card and asset.person_id = scope.staff_id_card)
or (
spouse.spouse_id_card is not null
and (
(
spouse.spouse_is_staff = 1
and asset.family_id = spouse.spouse_id_card
and asset.person_id = spouse.spouse_id_card
)
or (
(spouse.spouse_is_staff is null or spouse.spouse_is_staff != 1)
and asset.family_id = scope.staff_id_card
and asset.person_id = spouse.spouse_id_card
)
)
)
), 0) as total_asset,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as total_debt,
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0)
) &lt;= coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and (
asset.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and asset.person_id = spouse.spouse_id_card)
)
), 0) * 1.5 then 1
when (
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0)
) &lt;= coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and (
asset.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and asset.person_id = spouse.spouse_id_card)
)
), 0) * 3 then 2
when (
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0)
) &gt; coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and (
asset.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and asset.person_id = spouse.spouse_id_card)
)
), 0) * 3 then 3
else 3
end as risk_level_sort
from (
<include refid="projectEmployeeScopeSql"/>
) scope
left join ccdi_base_staff base_staff
on base_staff.id_card = scope.staff_id_card
left join (
<include refid="spouseRelationSql"/>
) spouse
on spouse.person_id = scope.staff_id_card
), 0) as comparison_amount
from (
<include refid="projectEmployeeScopeSql"/>
) scope
left join ccdi_base_staff base_staff
on base_staff.id_card = scope.staff_id_card
left join (
<include refid="spouseRelationSql"/>
) spouse
on spouse.person_id = scope.staff_id_card
) source
) aggregated
order by risk_level_sort desc, comparison_amount desc, staff_name asc
</select>
@@ -306,6 +314,7 @@
aggregated.project_id,
aggregated.staff_id_card,
aggregated.spouse_id_card,
aggregated.spouse_is_staff,
aggregated.staff_code,
aggregated.staff_name,
aggregated.dept_name,
@@ -344,89 +353,136 @@
end as summary_risk_level_name
from (
select
#{projectId} as project_id,
scope.staff_id_card,
scope.staff_code,
scope.staff_name,
scope.dept_name,
spouse.spouse_id_card,
coalesce(base_staff.annual_income, 0) as self_income,
coalesce(spouse.spouse_income, 0) as spouse_income,
coalesce(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
source.*,
case
when coalesce((
when source.self_asset_record_count = 0
or source.spouse_staff_asset_record_count = 0 then 1
else 0
end as missing_self_asset_info,
case
when source.self_debt_record_count = 0
or source.spouse_staff_debt_record_count = 0 then 1
else 0
end as missing_self_debt_info
from (
select
#{projectId} as project_id,
scope.staff_id_card,
scope.staff_code,
scope.staff_name,
scope.dept_name,
spouse.spouse_id_card,
spouse.spouse_is_staff,
coalesce(base_staff.annual_income, 0) as self_income,
coalesce(spouse.spouse_income, 0) as spouse_income,
coalesce(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
coalesce((
select count(1)
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and asset.person_id = scope.staff_id_card
), 0) = 0 then 1
else 0
end as missing_self_asset_info,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and asset.person_id = scope.staff_id_card
), 0) as self_total_asset,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and spouse.spouse_id_card is not null
and asset.person_id = spouse.spouse_id_card
), 0) as spouse_total_asset,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and (
asset.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and asset.person_id = spouse.spouse_id_card)
)
), 0) as total_asset,
case
when coalesce((
), 0) as self_asset_record_count,
case
when spouse.spouse_is_staff = 1 then coalesce((
select count(1)
from ccdi_asset_info asset
where asset.family_id = spouse.spouse_id_card
and asset.person_id = spouse.spouse_id_card
), 0)
else 1
end as spouse_staff_asset_record_count,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where asset.family_id = scope.staff_id_card
and asset.person_id = scope.staff_id_card
), 0) as self_total_asset,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where spouse.spouse_id_card is not null
and (
(
spouse.spouse_is_staff = 1
and asset.family_id = spouse.spouse_id_card
and asset.person_id = spouse.spouse_id_card
)
or (
(spouse.spouse_is_staff is null or spouse.spouse_is_staff != 1)
and asset.family_id = scope.staff_id_card
and asset.person_id = spouse.spouse_id_card
)
)
), 0) as spouse_total_asset,
coalesce((
select sum(coalesce(asset.current_value, 0))
from ccdi_asset_info asset
where (asset.family_id = scope.staff_id_card and asset.person_id = scope.staff_id_card)
or (
spouse.spouse_id_card is not null
and (
(
spouse.spouse_is_staff = 1
and asset.family_id = spouse.spouse_id_card
and asset.person_id = spouse.spouse_id_card
)
or (
(spouse.spouse_is_staff is null or spouse.spouse_is_staff != 1)
and asset.family_id = scope.staff_id_card
and asset.person_id = spouse.spouse_id_card
)
)
)
), 0) as total_asset,
coalesce((
select count(1)
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
), 0) = 0 then 1
else 0
end as missing_self_debt_info,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
), 0) as self_total_debt,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where spouse.spouse_id_card is not null
and debt.person_id = spouse.spouse_id_card
), 0) as spouse_total_debt,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as total_debt,
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
), 0) as self_debt_record_count,
case
when spouse.spouse_is_staff = 1 then coalesce((
select count(1)
from ccdi_debts_info debt
where debt.person_id = spouse.spouse_id_card
), 0)
else 1
end as spouse_staff_debt_record_count,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
), 0) as self_total_debt,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where spouse.spouse_id_card is not null
and debt.person_id = spouse.spouse_id_card
), 0) as spouse_total_debt,
coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as comparison_amount
from (
<include refid="projectEmployeeScopeSql"/>
) scope
left join ccdi_base_staff base_staff
on base_staff.id_card = scope.staff_id_card
left join (
<include refid="spouseRelationSql"/>
) spouse
on spouse.person_id = scope.staff_id_card
where scope.staff_id_card = #{staffIdCard}
), 0) as total_debt,
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as comparison_amount
from (
<include refid="projectEmployeeScopeSql"/>
) scope
left join ccdi_base_staff base_staff
on base_staff.id_card = scope.staff_id_card
left join (
<include refid="spouseRelationSql"/>
) spouse
on spouse.person_id = scope.staff_id_card
where scope.staff_id_card = #{staffIdCard}
) source
) aggregated
</select>
@@ -437,7 +493,15 @@
asset.asset_sub_type,
case
when asset.person_id = #{staffIdCard} then base_staff.name
else spouse.relation_name
else coalesce(holder_staff.name, (
select max(relation.relation_name)
from ccdi_staff_fmy_relation relation
where relation.person_id = #{staffIdCard}
and relation.relation_cert_no = asset.person_id
and relation.status = 1
and relation.is_emp_family = 1
and relation.relation_type = '配偶'
))
end as holder_name,
asset.person_id as holder_id_card,
asset.current_value,
@@ -445,16 +509,24 @@
from ccdi_asset_info asset
left join ccdi_base_staff base_staff
on base_staff.id_card = #{staffIdCard}
left join ccdi_staff_fmy_relation spouse
on spouse.person_id = #{staffIdCard}
and spouse.status = 1
and spouse.relation_type = '配偶'
and spouse.relation_cert_no = asset.person_id
where asset.family_id = #{staffIdCard}
and (
asset.person_id = #{staffIdCard}
or (#{spouseIdCard} is not null and asset.person_id = #{spouseIdCard})
)
left join ccdi_base_staff holder_staff
on holder_staff.id_card = asset.person_id
where (asset.family_id = #{staffIdCard} and asset.person_id = #{staffIdCard})
or (
#{spouseIdCard} is not null
and (
(
#{spouseIsStaff} = 1
and asset.family_id = #{spouseIdCard}
and asset.person_id = #{spouseIdCard}
)
or (
(#{spouseIsStaff} is null or #{spouseIsStaff} != 1)
and asset.family_id = #{staffIdCard}
and asset.person_id = #{spouseIdCard}
)
)
)
order by
case when asset.person_id = #{staffIdCard} then 1 else 2 end,
asset.valuation_date desc,
@@ -469,7 +541,15 @@
debt.creditor_type,
case
when debt.person_id = #{staffIdCard} then base_staff.name
else spouse.relation_name
else coalesce(owner_staff.name, (
select max(relation.relation_name)
from ccdi_staff_fmy_relation relation
where relation.person_id = #{staffIdCard}
and relation.relation_cert_no = debt.person_id
and relation.status = 1
and relation.is_emp_family = 1
and relation.relation_type = '配偶'
))
end as owner_name,
debt.person_id as owner_id_card,
debt.principal_balance,
@@ -477,11 +557,8 @@
from ccdi_debts_info debt
left join ccdi_base_staff base_staff
on base_staff.id_card = #{staffIdCard}
left join ccdi_staff_fmy_relation spouse
on spouse.person_id = #{staffIdCard}
and spouse.status = 1
and spouse.relation_type = '配偶'
and spouse.relation_cert_no = debt.person_id
left join ccdi_base_staff owner_staff
on owner_staff.id_card = debt.person_id
where debt.person_id = #{staffIdCard}
or (#{spouseIdCard} is not null and debt.person_id = #{spouseIdCard})
order by

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

@@ -220,6 +220,20 @@ class CcdiBankStatementMapperXmlTest {
}
}
@Test
void targetCount_shouldOnlyUseStatementCretNoMatchedStaff() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
String selectSql = extractSelect(xml, "countMatchedStaffCountByProjectId");
assertTrue(selectSql.contains("select count(distinct trim(bs.cret_no))"), selectSql);
assertTrue(selectSql.contains("inner join ccdi_base_staff staff on staff.id_card = trim(bs.cret_no)"), selectSql);
assertFalse(selectSql.contains("ccdi_staff_fmy_relation"), selectSql);
assertFalse(selectSql.contains("ccdi_account_info"), selectSql);
assertFalse(selectSql.contains("LE_ACCOUNT_NO"), selectSql);
}
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {
Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));
@@ -242,6 +256,15 @@ class CcdiBankStatementMapperXmlTest {
return boundSql.getSql().replaceAll("\\s+", " ").trim();
}
private String extractSelect(String xml, String selectId) {
String start = "<select id=\"" + selectId + "\"";
int startIndex = xml.indexOf(start);
assertTrue(startIndex >= 0, "missing select: " + selectId);
int endIndex = xml.indexOf("</select>", startIndex);
assertTrue(endIndex >= 0, "missing closing select tag: " + selectId);
return xml.substring(startIndex, endIndex);
}
private void registerTypeAliases(TypeAliasRegistry typeAliasRegistry) {
typeAliasRegistry.registerAlias("map", Map.class);
}

View File

@@ -100,7 +100,7 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(6, countMatches(xml, "where 1 = 0"));
assertEquals(5, countMatches(xml, "where 1 = 0"));
}
@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
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE);

View File

@@ -90,13 +90,50 @@ class CcdiProjectOverviewMapperSqlTest {
void shouldExposeSuspiciousTransactionAggregationQuery() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
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_enterprise_base_info"), 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("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
@@ -159,4 +196,13 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(endIndex >= 0, "missing closing select tag: " + selectId);
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

@@ -17,6 +17,13 @@ class CcdiProjectSpecialCheckMapperDetailSqlTest {
assertTrue(xml.contains("select id=\"selectFamilyAssetItemsByScope\""));
assertTrue(xml.contains("select id=\"selectFamilyDebtItemsByScope\""));
assertTrue(xml.contains("scope.staff_id_card = #{staffIdCard}"));
assertTrue(xml.contains("spouseIsStaff=spouse_is_staff"));
assertTrue(xml.contains("relation.relation_cert_no as person_id"));
assertTrue(xml.contains("spouse_staff.annual_income"));
assertTrue(xml.contains("spouse.spouse_is_staff = 1"));
assertTrue(xml.contains("asset.family_id = spouse.spouse_id_card"));
assertTrue(xml.contains("source.spouse_staff_asset_record_count = 0"));
assertTrue(xml.contains("source.spouse_staff_debt_record_count = 0"));
assertTrue(xml.contains("incomeDetail"));
assertTrue(xml.contains("assetDetail"));
assertTrue(xml.contains("debtDetail"));
@@ -31,6 +38,9 @@ class CcdiProjectSpecialCheckMapperDetailSqlTest {
assertTrue(xml.contains("asset_main_type"));
assertTrue(xml.contains("asset_sub_type"));
assertTrue(xml.contains("holder_name"));
assertTrue(xml.contains("holder_staff.name"));
assertTrue(xml.contains("select max(relation.relation_name)"));
assertTrue(xml.contains("relation.relation_cert_no = asset.person_id"));
assertTrue(xml.contains("current_value"));
assertTrue(xml.contains("valuation_date"));
assertTrue(xml.contains("debt_name"));
@@ -38,6 +48,8 @@ class CcdiProjectSpecialCheckMapperDetailSqlTest {
assertTrue(xml.contains("debt_sub_type"));
assertTrue(xml.contains("creditor_type"));
assertTrue(xml.contains("owner_name"));
assertTrue(xml.contains("owner_staff.name"));
assertTrue(xml.contains("relation.relation_cert_no = debt.person_id"));
assertTrue(xml.contains("principal_balance"));
assertTrue(xml.contains("query_date"));
assertFalse(xml.contains("ccdi_project_overview_employee_result"));

View File

@@ -19,11 +19,25 @@ class CcdiProjectSpecialCheckMapperListSqlTest {
assertTrue(xml.contains("ccdi_base_staff"));
assertTrue(xml.contains("ccdi_staff_fmy_relation"));
assertTrue(xml.contains("relation_type = '配偶'"));
assertTrue(xml.contains("union all"));
assertTrue(xml.contains("relation.relation_cert_no as person_id"));
assertTrue(xml.contains("inner join ccdi_base_staff current_staff"));
assertTrue(xml.contains("spouse_staff.annual_income"));
assertTrue(xml.contains("spouse_is_staff"));
assertTrue(xml.contains("annual_income"));
assertTrue(xml.contains("current_value"));
assertTrue(xml.contains("principal_balance"));
assertTrue(listSql.contains("self_asset_record_count"));
assertTrue(listSql.contains("spouse_staff_asset_record_count"));
assertTrue(listSql.contains("self_debt_record_count"));
assertTrue(listSql.contains("spouse_staff_debt_record_count"));
assertTrue(listSql.contains("source.self_asset_record_count = 0"));
assertTrue(listSql.contains("source.spouse_staff_asset_record_count = 0"));
assertTrue(listSql.contains("source.self_debt_record_count = 0"));
assertTrue(listSql.contains("source.spouse_staff_debt_record_count = 0"));
assertTrue(listSql.contains("asset.family_id = spouse.spouse_id_card"));
assertTrue(listSql.contains("asset.person_id = spouse.spouse_id_card"));
assertTrue(listSql.contains("debt.person_id = spouse.spouse_id_card"));
assertTrue(listSql.contains("then 'MISSING_INFO'"));
assertTrue(listSql.contains("then '缺少信息'"));
assertTrue(listSql.contains("comparison_amount"));

View File

@@ -317,7 +317,7 @@ class CcdiBankTagServiceImplTest {
}
@Test
void shouldRollbackProjectStatusToProcessingWhenRebuildFails() {
void shouldMarkProjectTagFailedWhenRebuildFails() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("LARGE_TRANSACTION", "大额交易",
@@ -329,7 +329,7 @@ class CcdiBankTagServiceImplTest {
assertThrows(RuntimeException.class,
() -> service.rebuildProject(40L, null, "tester", TriggerType.MANUAL));
verify(projectService).updateProjectStatus(40L, "0", "tester");
verify(projectService).updateProjectStatus(40L, "4", "tester");
}
@Test

View File

@@ -112,7 +112,7 @@ class CcdiBankTagServiceRiskCountRefreshTest {
verify(taskMapper).updateTask(argThat(task -> "FAILED".equals(task.getStatus())
&& "refresh failed".equals(task.getErrorMessage())));
verify(projectService).updateProjectStatus(40L, "0", "tester");
verify(projectService).updateProjectStatus(40L, "4", "tester");
}
private CcdiBankTagRule buildRule() {

View File

@@ -60,6 +60,17 @@ class CcdiProjectOverviewReportPdfExporterTest {
assertTrue(exception.getMessage().contains(missingPath));
}
@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 String resolveTestFontPath() {
List<String> candidates = List.of(
"/System/Library/Fonts/STHeiti Medium.ttc",
@@ -91,6 +102,12 @@ class CcdiProjectOverviewReportPdfExporterTest {
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(

View File

@@ -29,6 +29,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
@@ -69,6 +70,9 @@ class CcdiProjectOverviewServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiBankStatementMapper bankStatementMapper;
@Mock
private CcdiModelParamMapper modelParamMapper;
@@ -427,10 +431,11 @@ class CcdiProjectOverviewServiceImplTest {
summary.put("mediumRiskCount", new BigDecimal("1"));
summary.put("lowRiskCount", new BigDecimal("3"));
when(overviewMapper.selectRiskCountSummaryByProjectId(43L)).thenReturn(summary);
when(bankStatementMapper.countMatchedStaffCountByProjectId(43L)).thenReturn(6);
service.refreshProjectRiskCounts(43L, "tester");
verify(projectMapper).updateRiskCountsByProjectId(eq(43L), eq(2), eq(1), eq(3), eq("tester"));
verify(projectMapper).updateRiskCountsByProjectId(eq(43L), eq(6), eq(2), eq(1), eq(3), eq("tester"));
}
@Test
@@ -490,6 +495,7 @@ class CcdiProjectOverviewServiceImplTest {
);
when(overviewEmployeeResultMapper.selectEmployeeHitRowsByProjectId(43L)).thenReturn(hitRows);
when(overviewEmployeeResultBuilder.build(43L, hitRows, "tester")).thenReturn(results);
when(bankStatementMapper.countMatchedStaffCountByProjectId(43L)).thenReturn(3);
service.refreshOverviewEmployeeResults(43L, "tester");
@@ -503,7 +509,7 @@ class CcdiProjectOverviewServiceImplTest {
inOrder.verify(overviewEmployeeResultMapper).selectEmployeeHitRowsByProjectId(43L);
inOrder.verify(overviewEmployeeResultBuilder).build(43L, hitRows, "tester");
inOrder.verify(overviewEmployeeResultMapper).insertBatch(results);
inOrder.verify(projectMapper).updateRiskCountsByProjectId(43L, 1, 1, 1, "tester");
inOrder.verify(projectMapper).updateRiskCountsByProjectId(43L, 3, 1, 1, 1, "tester");
}
@Test

View File

@@ -7,10 +7,12 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.event.CcdiProjectHistoryImportSubmittedEvent;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
@@ -25,11 +27,14 @@ import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -47,6 +52,9 @@ class CcdiProjectServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiBankTagTaskMapper bankTagTaskMapper;
@Mock
private LsfxAnalysisClient lsfxAnalysisClient;
@@ -55,13 +63,55 @@ class CcdiProjectServiceImplTest {
@Test
void shouldCountTaggingProjectsSeparately() {
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L);
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L, 0L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(1L, counts.getStatus3());
}
@Test
void shouldCountTagFailedProjectsSeparately() {
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L, 5L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(5L, counts.getStatus4());
}
@Test
void shouldReturnLatestFailedTagTaskOnFailedProjectDetail() {
Date endTime = new Date();
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setStatus("4");
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiBankTagTask failedTask = new CcdiBankTagTask();
failedTask.setErrorMessage("threshold missing");
failedTask.setEndTime(endTime);
when(bankTagTaskMapper.selectLatestFailedTaskByProjectId(40L)).thenReturn(failedTask);
CcdiProjectVO result = service.getProjectById(40L);
assertEquals("threshold missing", result.getLatestTagTaskErrorMessage());
assertEquals(endTime, result.getLatestTagTaskEndTime());
}
@Test
void shouldNotReturnLatestFailedTagTaskWhenProjectIsNotFailed() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setStatus("0");
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectVO result = service.getProjectById(40L);
assertNotNull(result);
assertNull(result.getLatestTagTaskErrorMessage());
verify(bankTagTaskMapper, never()).selectLatestFailedTaskByProjectId(any());
}
@Test
void shouldRejectUpdatingArchivedProjectToTagging() {
CcdiProject archived = new CcdiProject();
@@ -84,6 +134,16 @@ class CcdiProjectServiceImplTest {
() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
}
@Test
void shouldAllowWritingWhenProjectTagFailed() {
CcdiProject tagFailed = new CcdiProject();
tagFailed.setProjectId(40L);
tagFailed.setStatus("4");
when(projectMapper.selectById(40L)).thenReturn(tagFailed);
assertDoesNotThrow(() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
}
@Test
void shouldArchiveCompletedProject() {
CcdiProject project = new CcdiProject();
@@ -110,6 +170,16 @@ class CcdiProjectServiceImplTest {
assertThrows(ServiceException.class, () -> service.archiveProject(41L, "tester"));
}
@Test
void shouldRejectArchivingProjectWhenStatusIsTagFailed() {
CcdiProject project = new CcdiProject();
project.setProjectId(41L);
project.setStatus("4");
when(projectMapper.selectById(41L)).thenReturn(project);
assertThrows(ServiceException.class, () -> service.archiveProject(41L, "tester"));
}
@Test
void shouldRejectWritingWhenProjectIsArchived() {
CcdiProject archived = new CcdiProject();

View File

@@ -14,12 +14,25 @@ class CcdiProjectStatusSqlTest {
void shouldContainTaggingStatusInInitAndMigrationSql() throws IOException {
Path repoRoot = Path.of("..");
String initSql = Files.readString(repoRoot.resolve("sql/ccdi_project.sql"));
String prodInitSql = Files.readString(repoRoot.resolve("sql/ccdi_prod_init.sql"));
String migrationSql = Files.readString(repoRoot.resolve("sql/migration/2026-03-18-add-project-tagging-status.sql"));
String tagFailedMigrationSql =
Files.readString(repoRoot.resolve("sql/migration/2026-05-27-add-project-tag-failed-status.sql"));
assertTrue(initSql.contains("打标中"));
assertTrue(initSql.contains("'3'"));
assertTrue(migrationSql.contains("ccdi_project_status"));
assertTrue(migrationSql.contains("打标中"));
assertTrue(migrationSql.contains("'3'"));
assertTrue(initSql.contains("打标失败"));
assertTrue(initSql.contains("'4'"));
assertTrue(prodInitSql.contains("打标失败"));
assertTrue(prodInitSql.contains("'4','ccdi_project_status'"));
assertTrue(tagFailedMigrationSql.contains("ccdi_project_status"));
assertTrue(tagFailedMigrationSql.contains("打标失败"));
assertTrue(tagFailedMigrationSql.contains("'4'"));
assertTrue(tagFailedMigrationSql.contains("latest_task.status = 'FAILED'"));
assertTrue(tagFailedMigrationSql.contains("project.status IN ('0', '3')"));
}
}

View File

@@ -196,7 +196,7 @@
| `uncle_bank_consume` | 银行 | 消费贷款 | 银行 | 未结清银行消费贷款 |
| `uncle_bank_other` | 银行 | 其他贷款 | 银行 | 未结清银行其他贷款 |
| `uncle_not_bank` | 非银 | 非银行贷款 | 非银 | 未结清非银行贷款 |
| `uncle_credit_cart` | 银行 | 信用卡 | 银行 | 未结清信用卡 |
| `uncle_credit_card` | 银行 | 信用卡 | 银行 | 未结清信用卡 |
字段映射规则:
@@ -206,6 +206,7 @@
落库过滤规则:
- 当某组 `*_state``-9999` 时,表示不存在该类型负债,不生成明细记录
- 当某组 `principal_balance``debt_total_amount` 都为空或 `0`,且 `debt_status` 为空时,不生成明细记录
- 其余情况生成一条明细
@@ -220,6 +221,8 @@
- `enforce_lmt` -> `enforce_lmt`
- `adm_lmt` -> `adm_lmt`
负面风险字段值为 `-9999``-9999.0` 时,表示不存在对应风险类型;次数按 `0` 处理,金额按空值处理。
## 10. 最新征信判定与覆盖策略
系统只保留员工最新征信,规则如下:

View File

@@ -0,0 +1,35 @@
# 征信解析双接口与结果轮询后端实施计划
## 背景
根据 `天座征信解析接口文档.xlsx`,征信解析需要拆分为发起接口和结果接口:先通过 `/api/service/interface/invokeService/xfeature` 提交 HTML 远程地址,再通过 `/api/service/interface/invokeService/xfeatureResult` 按同一业务流水号查询解析结果。
## 实施内容
1. 后端客户端
- 保持 `CreditParseClient.parse(remotePath)``parse(model, remotePath)` 对外签名不变。
- 内部生成同一个 `serialNum`,发起接口提交 `serialNum/orgCode/runType/remotePath/model`
- 结果接口提交 `serialNum/orgCode/runType`,最多查询 5 次,每次间隔 2 秒。
- 外层严格校验 `success=true``code=10000`,业务层严格校验 `status_code=0`
- 仅当结果未就绪或 `payload` 为空时继续轮询,明确业务失败立即返回失败原因。
2. 配置
- 保留 `credit-parse.api.url` 作为发起接口地址。
- 新增 `credit-parse.api.result-url` 作为结果接口地址。
- `dev/uat/nas/pro` 环境 `credit-parse.api.org-code` 统一调整为 `999000`
3. Mock 服务
- `/xfeature` 读取 `remotePath` 生成 payload`serialNum` 暂存结果,只返回发起成功结构。
- `/xfeatureResult``serialNum` 返回暂存 payload未知流水号返回业务失败。
4. 征信维护落库
- 上传入口、HTML 落盘、`remotePath` 拼接、页面接口均保持不变。
- 原有落库逻辑继续读取最终结果中的 `lx_header/lx_debt/lx_publictype`
## 验证计划
- `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`
- `mvn -pl ccdi-lsfx,ccdi-info-collection -am compile`
- `cd lsfx-mock-server && pytest tests/test_credit_api.py tests/test_startup.py -q`

View File

@@ -0,0 +1,29 @@
# 账号库列表排除信贷客户后端实施计划
## 1. 目标
账号库管理列表不展示 `ccdi_account_info.owner_type = 'CREDIT_CUSTOMER'` 的信贷客户账号,避免信贷客户账号批量导入后进入页面列表并影响查询性能。
## 2. 实施范围
- 后端账号库列表查询 SQL
- 账号库导出查询复用同一筛选条件
- 本次不调整前端筛选项、接口参数、返回结构、新增编辑导入校验
## 3. 实施步骤
1.`CcdiAccountInfoMapper.xml``AccountInfoWhereClause` 增加固定条件:
`AND ai.owner_type <> 'CREDIT_CUSTOMER'`
2. 保持现有 `ownerType` 动态筛选逻辑不变,使 `ownerType=CREDIT_CUSTOMER` 查询自然返回空结果。
3. 不新增前端“信贷客户”筛选项,不扩展账号库维护端归属类型。
## 4. 验证要点
- 无筛选条件时列表不返回 `CREDIT_CUSTOMER` 数据。
- `ownerType=EMPLOYEE``RELATION``INTERMEDIARY``EXTERNAL` 时仍按原逻辑查询。
- `ownerType=CREDIT_CUSTOMER` 时返回空结果。
- 账号库导出与列表使用同一排除口径。
## 5. 前提
信贷客户账号导入 `ccdi_account_info` 时,`owner_type` 必须固定写入 `CREDIT_CUSTOMER`

View File

@@ -0,0 +1,24 @@
# 项目打标失败状态后端实施计划
## 保存路径确认
- 后端计划:`docs/plans/backend/2026-05-27-project-tag-failed-status-backend-implementation.md`
- 实施记录:`docs/reports/implementation/2026-05-27-project-tag-failed-status-implementation.md`
## 目标
新增正式项目状态 `4-打标失败`,打标任务失败后项目状态停留在失败态;项目详情接口在失败态下返回最近失败任务错误信息,列表接口不返回完整错误。
## 实施步骤
1. 扩展项目状态常量、实体注释、状态文案和状态统计 VO新增 `TAG_FAILED = "4"``status4`
2. 修改打标失败流转:`CcdiBankTagServiceImpl.rebuildProject` 捕获异常后保留任务失败信息,并将项目状态更新为 `4`
3. 新增 `CcdiBankTagTaskMapper.selectLatestFailedTaskByProjectId`,按 `id desc limit 1` 查询项目最近失败任务。
4. 扩展 `CcdiProjectVO`,只新增 `latestTagTaskErrorMessage``latestTagTaskEndTime``getProjectById` 仅在状态为 `4` 时组装失败任务信息。
5. 补充 SQL 初始化与迁移脚本,新增 `ccdi_project_status` 字典值 `4-打标失败`,并回填未归档且最新打标任务失败的 `0/3` 项目。
6. 补充后端单测覆盖失败状态流转、详情失败信息、`status4` 统计、`4` 状态可写和 SQL/Mapper 契约。
## 验证
- 执行本次相关后端测试类。
- 执行 `mvn -pl ccdi-project -am test` 观察全量状态并记录非本次问题。

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,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,28 @@
# 项目打标失败状态前端实施计划
## 保存路径确认
- 前端计划:`docs/plans/frontend/2026-05-27-project-tag-failed-status-frontend-implementation.md`
- 实施记录:`docs/reports/implementation/2026-05-27-project-tag-failed-status-implementation.md`
## 目标
前端支持 `4-打标失败` 状态展示;项目列表只展示失败状态和进入项目入口;项目详情页展示失败提示,并通过详情接口字段查看完整错误。
## 实施步骤
1. 在项目列表、项目详情、历史导入状态映射中增加 `4-打标失败`,使用失败红色样式。
2.`SearchBar` 和项目首页状态统计中增加 `4` 筛选与 `status4` 计数。
3. 在项目详情页头部下方增加打标失败提示,仅当 `projectInfo.projectStatus === "4"` 且存在 `latestTagTaskErrorMessage` 时展示。
4. 详情失败提示提供完整错误弹窗,内容只使用详情接口返回的 `latestTagTaskErrorMessage``latestTagTaskEndTime`
5. 项目状态轮询在状态脱离 `3-打标中` 后停止,因此遇到 `4` 自动停止并展示失败信息。
6. `UploadData.vue``4``0-进行中` 处理:允许上传、拉取、征信导入,禁用查看报告入口。
7. `ParamConfig.vue` 维持只锁定 `3-打标中``2-已归档`,因此 `4` 状态允许保存参数并触发重新打标。
8. 补充静态单测覆盖状态映射、详情失败提示、列表不展示完整错误、筛选计数和失败态操作口径。
## 验证
- 前端命令执行前先通过 `nvm use` 切换到项目 Node 版本。
- 执行相关静态单测。
- 执行 `npm run build:prod`
- 在真实业务页面路由中验证列表和详情页显示效果,不打开 prototype 页面。

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,28 @@
# 结果总览详情弹窗资产与征信页签前端实施计划
## 目标
在项目详情“结果总览 - 风险总览 - 查看详情”弹窗中,将资产分析和征信页签从占位展示改为真实详情展示。
## 实施范围
- `资产分析` 页签复用员工家庭资产负债专项核查中的资产详情展示组件。
- `征信摘要` 页签改名为 `征信详情`,复用征信信息维护中的详情展示结构。
- 不新增后端接口不调整后端权限、SQL 或业务口径。
## 前端改动
- 抽取征信详情展示组件 `CreditInfoDetail`,并在征信信息维护详情弹窗中复用。
- 新增征信详情标准化 helper以详情接口返回的 `negativeInfo``debtList` 计算展示字段,不依赖征信列表行。
- 在项目分析弹窗中按页签懒加载资产详情和征信详情:
- 资产详情调用 `getFamilyAssetLiabilityDetail(projectId, staffIdCard)`
- 征信详情调用 `getCreditInfoDetail(staffIdCard)`
- 同一次打开弹窗内已加载页签不重复请求。
- 缺少项目或人员身份证号时只展示页签内提示,不发起请求。
- `getCreditInfoDetail` 使用 `encodeURIComponent(personId)` 拼接路径。
## 验证计划
- 使用 `nvm use` 后运行相关前端静态测试。
- 运行前端生产构建验证组件引用与模板编译。
- 启动真实前后端后,用浏览器进入项目详情页,在结果总览风险总览中点击“查看详情”,分别验证资产分析和征信详情页签。

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

View File

@@ -0,0 +1,26 @@
# 上传流水格式与行外命名提示实施记录
## 修改内容
- 更新项目详情“上传数据”页的“批量上传流水文件”弹窗提示,明确支持 `PDF``CSV``XLSX` 格式。
- 将批量上传前端格式校验收敛为 `.pdf``.csv``.xlsx`,错误提示统一使用 `XLSX` 表述。
- 同步后端 `/ccdi/file-upload/batch` 接口格式校验与错误提示,避免绕过前端上传 `.xls` 后出现页面与接口口径不一致。
- 在上传提示中补充行外流水文件命名规则:`客户身份证号-其他内容`
## 影响范围
- 前端文件:`ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- 后端文件:`ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
- 影响功能:项目详情上传数据页的“上传流水”批量上传弹窗与批量上传接口格式校验。
## 验证情况
- `mvn -pl ccdi-project -am compile -DskipTests`:通过。
- `npm run lint -- --no-fix`:未执行,前端工程未配置 `lint` 脚本。
- `source ~/.nvm/nvm.sh && nvm use && npm run build:prod`:通过,仅存在既有包体积 warning。
- `sh bin/restart_java_backend.sh`:后端构建并启动成功。
- 前端使用 `nvm use` 后以 `npm_config_port=9528 npm run dev` 启动,真实页面进入项目详情“上传数据”页,点击“上传流水”后确认弹窗展示:
- `支持 PDF、CSV、XLSX 格式文件最多100个文件单个文件不超过50MB`
- `行外流水文件命名规则:客户身份证号-其他内容`
- 调用 `/ccdi/file-upload/batch` 上传伪装文件名 `test.xls`,接口返回 `文件 test.xls 格式不支持, 仅支持 PDF, CSV, XLSX 文件`
- 测试结束后已关闭本次启动的前端 `9528` 端口与后端 `62318` 端口进程。

View File

@@ -0,0 +1,35 @@
# 登录页前端凭据清理实施记录
## 背景
- 登录页存在默认预填账号 `admin` 和密码 `admin123`
- 登录页“记住密码”逻辑会把用户密码写入浏览器 Cookie。
- 本次按要求移除前端代码中的账号密码,并将该类问题写入项目根 `AGENTS.md`
## 修改内容
- 修改 `ruoyi-ui/src/views/login.vue`
- 将登录表单默认 `username``password` 改为空字符串。
- 将“记住密码”文案调整为“记住账号”。
- 停止从 Cookie 读取密码,进入登录页时主动清理历史 `password` Cookie。
- 停止登录时向 Cookie 写入密码,仅保留用户名记忆能力。
- 修改 `AGENTS.md`
- 增加禁止在前端源码、配置、示例数据或页面默认值中硬编码或预填真实账号密码的规则。
- 增加登录页不得将密码保存到 Cookie、localStorage 或 sessionStorage 的规则。
- 明确登录页最多只能保存用户名,不允许保存密码或预填默认密码。
## 影响范围
- 影响前端登录页默认展示与“记住”行为。
- 不修改后端登录接口、账号认证逻辑、数据库用户密码哈希或权限体系。
- 已存在于用户浏览器中的旧 `password` Cookie 会在访问登录页时被清理。
## 验证记录
- 源码检查:`rg -n "admin123|Cookies\\.set\\(\\\"password\\\"|Cookies\\.get\\(\\\"password\\\"|记住密码|decrypt\\(|encrypt\\(" ruoyi-ui/src -S`
- 结果:未命中登录页默认账号密码、密码 Cookie 读写和“记住密码”文案;仅保留 `jsencrypt.js` 通用工具函数定义。
- 前端构建:`source ~/.nvm/nvm.sh && nvm use && node -v && npm run build:prod`
- 结果Node `v14.21.3` 下构建成功;存在原有资源体积 warning。
- 真实页面验证:启动前端开发服务后,使用 `browser-use` 打开 `http://127.0.0.1:8080/login`
- 结果:页面存在账号与密码输入框各 1 个,“记住账号”文案 1 个,“记住密码”文案 0 个,页面 DOM 不包含 `admin``admin123`
- 测试进程清理:已关闭本次启动的 `127.0.0.1:8080` 前端开发服务。

View File

@@ -0,0 +1,43 @@
# PDF 中文字体离线文件下载记录
## 保存路径确认
- 实施记录保存路径:`docs/reports/implementation/2026-05-11-pdf-font-offline-download.md`
- 离线字体文件保存路径:`deploy/fonts/wqy-microhei.ttc`
## 背景
生产环境无法在线安装中文字体包,需要提前准备 PDF 导出使用的中文字体文件,保证生产配置项 `ccdi.report.pdf-font-path` 指向的字体可以离线放置。
## 修改内容
- 新增离线字体文件:`deploy/fonts/wqy-microhei.ttc`
- 字体来源Debian 官方包源 `fonts-wqy-microhei_0.2.0-beta-4_all.deb`
- 从离线包中提取路径:`usr/share/fonts/truetype/wqy/wqy-microhei.ttc`
## 生产放置方式
生产服务器解压或上传后,将字体放到配置指定路径:
```bash
sudo mkdir -p /usr/share/fonts/truetype/wqy
sudo cp wqy-microhei.ttc /usr/share/fonts/truetype/wqy/wqy-microhei.ttc
sudo chmod 644 /usr/share/fonts/truetype/wqy/wqy-microhei.ttc
```
如果服务器存在 `fc-cache`,可执行:
```bash
sudo fc-cache -fv
```
## 验证记录
- 文件大小:约 4.9M
- 文件类型TrueType font collection data
- SHA256
```text
2420e8078af796b19a3f6ef13de527a1a91c1e7171eea115926c614ced1009b3
```

View File

@@ -0,0 +1,29 @@
# 用户修改密码强密码校验实施记录
## 保存路径检查
- 本次为功能修改实施记录,保存路径确认使用 `docs/reports/implementation/`
## 修改内容
- 后端新增 `PasswordStrengthUtils`,统一校验强密码规则。
- 个人中心修改密码接口 `/system/user/profile/updatePwd` 在旧密码正确后,校验新密码必须满足强密码要求,再校验新旧密码不能相同。
- 前端个人中心“修改密码”表单同步强密码校验提示,提交前拦截弱密码。
- 新增后端单元测试覆盖强密码通过、缺少字符类型、长度不合规、空白与非法字符等场景。
## 强密码规则
- 长度为 8 到 20 位。
- 必须同时包含大写字母、小写字母、数字和特殊字符。
- 不能包含空格或字符:`< > " ' \ |`
## 影响范围
- 影响用户个人中心修改密码功能。
- 不调整登录、注册、管理员新增用户、管理员重置密码等其他密码入口。
## 验证情况
- `mvn -pl ruoyi-admin -am -Dtest=PasswordStrengthUtilsTest -Dsurefire.failIfNoSpecifiedTests=false test`通过3 个强密码规则用例全部成功。
- `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && node -v && npm run build:prod`通过Node 版本为 `v14.21.3`;构建仅保留既有 asset size / entrypoint size 警告。
- 真实页面验证:登录 `http://localhost:9528/user/profile`,进入“修改密码”,输入新密码 `Abc12345` 后点击保存,页面展示强密码校验提示并拦截提交。

View File

@@ -0,0 +1,64 @@
# 征信解析远程路径调用改造实施记录
## 背景
天座征信解析接口调用方式变更:不再直接 multipart 上传 HTML 文件,改为先将 HTML 保存到业务服务器可访问目录,再将完整远程访问地址作为 `remotePath``application/x-www-form-urlencoded` 表单参数提交到新接口。
本次按新接口文档确认输出外层结构为 `success/code/data/mappingOutputFields`,其中 `mappingOutputFields.payload` 内部仍沿用原有 `lx_header/lx_debt/lx_publictype` 结构。
## 修改内容
1. 后端上传处理
- 上传入口 `/ccdi/creditInfo/upload` 保持不变。
- 新增 `CreditHtmlStorageService`,校验通过后的 HTML 保存到 `ruoyi.profile/credit-html/...`
- 根据 `credit-parse.api.file-public-base-url``/profile/credit-html/...` 拼接生成 `remotePath`
- 征信维护落库逻辑改为调用 `CreditParseClient.parse(remotePath)`,后续日期校验、落库、失败记录逻辑保持原样。
2. 天座接口客户端
- `CreditParseClient` 改为提交 `serialNum/orgCode/runType/remotePath/model` 表单参数。
- `HttpUtil` 增加 `postUrlEncodedForm` 方法,统一提交 `application/x-www-form-urlencoded`
- 新增 `CreditParseInvokeResponse``CreditParseInvokeData`,承载新外层响应结构。
- 业务解析只读取 `data.mappingOutputFields` 下的 `message/status_code/payload`
3. 配置
- `application-dev.yml``application-pro.yml``application-nas.yml``application-uat.yml` 同步调整 `credit-parse.api.url``/api/service/interface/invokeService/xfeature`
- 新增 `credit-parse.api.file-public-base-url``org-code=902000``run-type=1``model=LXCUSTALL`
4. 本地 mock
- `lsfx-mock-server` 新增支持 `/api/service/interface/invokeService/xfeature`
- mock 接收新表单参数并通过 `remotePath` 读取 HTML。
- mock 返回新外层结构payload 仍按旧结构生成。
5. 测试覆盖
- `CreditParseControllerTest` 覆盖新调试入口、默认模型、客户端异常、表单参数完整性。
- `CcdiCreditInfoServiceImplTest` 覆盖 HTML 保存路径与 `remotePath` 拼接、旧日期拦截、成功落库、从新外层结构读取旧 payload。
## 验证结果
1. 接口文档核对
- 已读取 `天座征信解析接口文档.xlsx`,确认调用方式为 `POST application/x-www-form-urlencoded`
- 已确认输入参数为 `serialNum/orgCode/runType/remotePath/model`
- 已确认输出结构为 `success/code/data/mappingOutputFields`
2. 单元测试与编译
- `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`通过4 个用例成功。
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过3 个用例成功。
- `mvn -pl ccdi-lsfx,ccdi-info-collection -am compile`:通过。
3. 联调验证
- 通过 `bin/restart_java_backend.sh restart` 启动后端。
- 启动本地 mock 后,通过 `/ccdi/creditInfo/upload` 上传临时 HTML。
- 后端日志确认调用参数包含:
- `model=LXCUSTALL`
- `remotePath=http://127.0.0.1:62318/profile/credit-html/2026/05/12/credit_remote_path_test_20260512190228A001.html`
- mock 日志确认收到 `POST /api/service/interface/invokeService/xfeature` 并返回 200。
- 上传结果:`successCount=1``failureCount=0`
- 列表与详情均可查询到解析后的征信负面信息和债务信息。
- 联调测试身份证号 `330781199001019999` 对应数据已通过删除接口清理,复查列表 `total=0`
## 影响范围
- 影响征信维护上传入口、征信解析客户端、本地 mock、相关环境配置。
- 前端上传入口和页面接口路径不变。
- 不保留旧 multipart 外部接口兼容逻辑。

View File

@@ -0,0 +1,39 @@
# 征信解析接口调用日志补充实施记录
## 背景
为便于联调排查,需要在调用天座征信解析接口时记录实际请求地址、表单参数以及接口返回的原始 JSON。
后续根据真实返回样例确认外层是否成功只看 `success` 字段,`code` 仅记录日志不参与成功判断,且 `payload` 可能以 JSON 字符串形式返回。
## 修改内容
1. `CreditParseClient`
- 调用前打印 `credit-parse.api.url` 和本次表单参数 `serialNum/orgCode/runType/remotePath/model`
- 调用后打印接口返回的原始 JSON 字符串。
- 原始 JSON 打印后再反序列化为现有 `CreditParseInvokeResponse`,保持后续业务处理逻辑不变。
- 按最新返回结构兼容字符串形式的 `payload``code` 仅保留日志输出。
2. `HttpUtil`
- 新增 `postUrlEncodedFormForString` 方法。
- 该方法沿用 `application/x-www-form-urlencoded` 提交流程,但返回原始响应字符串,供征信解析调用日志记录使用。
3. 测试
- 调整 `CreditParseControllerTest` 中征信解析客户端测试,验证新方法仍提交 `serialNum/orgCode/runType/remotePath/model` 参数,并可解析返回 JSON。
4. `CcdiCreditInfoServiceImpl`
- 平台外层成功判断只检查 `success=true`,不再检查 `code`
5. `lsfx-mock-server`
- 征信解析 Mock 外层 `code` 同步调整为 `10000`,保持本地联调返回结构一致。
## 影响范围
- 仅影响征信解析接口调用日志与该接口的响应读取方式。
- 不改变请求参数、接口地址配置、返回 DTO、征信信息落库和页面交互。
## 验证
- `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`:通过,已覆盖调用日志和 `payload` 字符串解析。
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`:通过,已覆盖 `success=true` 时不依赖固定 `code`
- `mvn -pl ccdi-lsfx,ccdi-info-collection -am compile`:通过。
- `git diff --check`:通过。

View File

@@ -0,0 +1,56 @@
# 征信解析双接口与结果轮询实施记录
## 背景
本次按 `天座征信解析接口文档.xlsx` 调整征信解析调用方式:由单次发起接口直接获取 `payload`,改为发起接口 `/api/service/interface/invokeService/xfeature` 加结果接口 `/api/service/interface/invokeService/xfeatureResult` 的两步调用。
## 修改内容
1. `CreditParseClient`
- 保持原有 `parse` 方法签名不变。
- 发起接口提交 `serialNum/orgCode/runType/remotePath/model`
- 结果接口使用同一 `serialNum` 提交 `serialNum/orgCode/runType`
- 结果接口最多轮询 5 次,每次间隔 2 秒。
- 严格校验外层 `success=true``code=10000` 和业务层 `status_code=0`
2. 配置
- `application-dev.yml``application-uat.yml``application-nas.yml``application-pro.yml` 新增 `credit-parse.api.result-url`
- 上述环境的 `credit-parse.api.org-code` 统一调整为 `999000`
3. `CcdiCreditInfoServiceImpl`
- 征信维护落库前补充外层 `code=10000` 校验。
- 上传入口、HTML 保存、`remotePath` 生成和落库字段映射保持不变。
4. `lsfx-mock-server`
- `/xfeature` 改为发起接口,只暂存结果并返回发起成功结构。
- 新增 `/xfeatureResult`,按 `serialNum` 返回暂存 payload。
- Mock README 和启动路由说明同步更新。
5. 测试
- Java 测试补充同一 `serialNum` 双接口调用、5 次轮询、2 秒间隔和外层状态码校验。
- Mock 测试补充发起接口、结果接口、缺参、未知流水号和 payload 返回。
## 影响范围
- 影响征信解析客户端、征信维护上传后的解析调用、征信解析 Mock 服务和相关环境配置。
- 不涉及前端页面、前端 API、数据库表结构和业务 payload 字段变更。
## 验证
- `mvn -pl ccdi-lsfx -Dtest=CreditParseControllerTest test`通过6 个用例成功。
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过4 个用例成功。
- `mvn -pl ccdi-lsfx,ccdi-info-collection -am compile`:通过。
- `cd lsfx-mock-server && python3 -m pytest tests/test_credit_api.py tests/test_startup.py -q`通过9 个用例成功。
- `git diff --check`:通过。
- 后端联调:
- 启动 `lsfx-mock-server`,并通过 `bin/restart_java_backend.sh restart` 重启后端。
- 调用 `/login/test` 获取测试令牌后,通过 `/ccdi/creditInfo/upload` 上传本轮 HTML 测试文件。
- 上传结果:`totalCount=1``successCount=1``failureCount=0`
- 列表按身份证号 `330781199001019914` 查询到 1 条征信记录。
- 后端日志确认同一个 `serialNum` 先调用 `/api/service/interface/invokeService/xfeature`,再调用 `/api/service/interface/invokeService/xfeatureResult`
- 调用删除接口清理本轮测试数据,回查列表 `total=0`
- 本轮启动的 mock 和后端进程已关闭。
- 浏览器验证说明:
- 已使用 browser-use 打开本地前端并完成登录。
- 后端重启后,浏览器旧登录态触发接口 500且 browser-use 安全策略阻止通过 `javascript:` URL 清理本地状态,因此未继续执行页面上传动作。
- 已改用真实后端接口完成上传、落库、日志和清理闭环验证。

View File

@@ -0,0 +1,30 @@
# 征信解析信用卡字段前缀调整实施记录
## 背景
征信解析负债字段中,信用卡字段前缀需要由 `uncle_credit_cart_*` 调整为 `uncle_credit_card_*`,保持字段命名与信用卡含义一致。
## 修改内容
1. 后端字段装配
- `CreditInfoPayloadAssembler` 中信用卡负债映射前缀由 `uncle_credit_cart` 调整为 `uncle_credit_card`
2. Mock 字段生成
- `lsfx-mock-server/config/credit_feature_schema.json` 中信用卡字段同步调整为:
- `uncle_credit_card_bal`
- `uncle_credit_card_lmt`
- `uncle_credit_card_state`
3. 文档
- `docs/design/2026-03-23-credit-info-maintenance-design.md` 中信用卡字段前缀同步调整。
## 影响范围
- 仅影响征信解析 payload 中信用卡负债字段的读取与本地 Mock 生成字段。
- 不涉及接口成功判断、数据库结构、前端页面和其他负债类型。
## 验证
- `mvn -pl ccdi-info-collection -am -Dtest=CreditInfoPayloadAssemblerTest,CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过10 个用例成功。
- `cd lsfx-mock-server && python3 -m pytest tests/test_credit_api.py tests/test_startup.py -q`通过9 个用例成功。
- `git diff --check`:通过。

View File

@@ -0,0 +1,36 @@
# 征信解析缺失标记过滤实施记录
## 背景
征信解析返回中,负债字段的 `*_state` 若为 `-9999`,表示不存在该类型负债;负面风险字段中 `-9999``-9999.0` 也表示不存在对应风险类型,不能按真实负债或负面风险指标落库。
## 修改内容
1. 负债明细装配
- `CreditInfoPayloadAssembler` 中新增 `-9999` 缺失标记识别。
- 当某组负债的 `*_state``-9999` 时,直接跳过该负债类型,不生成 `ccdi_debts_info` 明细。
- 数值和状态转换过程中同步将 `-9999` 视为空值,避免缺失标记落库。
2. 负面风险装配
- `lx_publictype` 中次数字段为 `-9999` 时按 `0` 处理。
- `lx_publictype` 中金额字段为 `-9999``-9999.0` 时按空值处理。
3. 测试
- 补充 `*_state=-9999` 时跳过负债类型的单测。
- 补充负面风险 `-9999` 转换为 `0/null` 的单测。
4. 文档
- 更新 `docs/design/2026-03-23-credit-info-maintenance-design.md` 中负债过滤和负面风险缺失值规则。
## 影响范围
- 仅影响征信解析 payload 到负债明细、负面风险表的装配逻辑。
- 不涉及接口调用、成功判断、数据库结构和前端页面。
## 验证
- `mvn -pl ccdi-info-collection -am -Dtest=CreditInfoPayloadAssemblerTest,CcdiCreditInfoServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`通过10 个用例成功。
- `git diff --check`:通过。
- 使用 `/Users/wkc/Downloads/zxjx.txt` 按当前规则复核:
- 负债明细仅保留 `uncle_bank_manage``uncle_not_bank` 两类。
- 负面风险 `civil/enforce/adm``-9999` 均按无对应风险处理。

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