Compare commits
26 Commits
0bf73a923f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ce66dc3ba8 | |||
| f3c1e2ea93 | |||
| 87b2352001 | |||
| 999350265b | |||
| f8ee1ecf1c | |||
| 35467fd361 | |||
| 64cb847db3 | |||
| bf290c509c | |||
| c5b2033a3d | |||
| d45e9410ef | |||
| 457e6c1d27 | |||
| 850f97ea22 | |||
| de6e6bd628 | |||
| 3a867e5857 | |||
| 19a60c987e | |||
| 7ce721ef93 | |||
| 000e8698a5 | |||
| 9d3e8beceb | |||
| 0ea504f6b3 | |||
| a39594faf8 | |||
| 1b45296df3 | |||
| 1fadb38d99 | |||
| 9917d10e59 | |||
| be443d1b31 | |||
| b822cc202e | |||
| 598f5dec1c |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -97,3 +97,7 @@ tongweb_62318.properties
|
||||
.superpowers/
|
||||
|
||||
tmp/
|
||||
|
||||
.codegraph/
|
||||
|
||||
.claude/
|
||||
@@ -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
669
CLAUDE.md
@@ -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>
|
||||
```
|
||||
@@ -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 "$@"
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
|
||||
<sql id="AccountInfoWhereClause">
|
||||
WHERE 1 = 1
|
||||
AND ai.owner_type <> 'CREDIT_CUSTOMER'
|
||||
<if test="query.staffName != null and query.staffName != ''">
|
||||
AND (
|
||||
(ai.owner_type = 'EMPLOYEE' AND bs.name LIKE CONCAT('%', #{query.staffName}, '%'))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
// 忽略临时文件删除失败,避免影响主流程返回
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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 文件");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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-已归档 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -23,4 +23,7 @@ public class CcdiProjectStatusCountsVO {
|
||||
|
||||
/** 打标中项目数(状态3) */
|
||||
private Long status3;
|
||||
|
||||
/** 打标失败项目数(状态4) */
|
||||
private Long status4;
|
||||
}
|
||||
|
||||
@@ -33,4 +33,6 @@ public class CcdiProjectSuspiciousTransactionItemVO {
|
||||
private Boolean hasModelRuleHit;
|
||||
|
||||
private Boolean hasNameListHit;
|
||||
|
||||
private String nameListHitType;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ public class CcdiProjectVO {
|
||||
/** 更新时间 */
|
||||
private Date updateTime;
|
||||
|
||||
/** 最近一次打标失败原因 */
|
||||
private String latestTagTaskErrorMessage;
|
||||
|
||||
/** 最近一次打标失败结束时间 */
|
||||
private Date latestTagTaskEndTime;
|
||||
|
||||
/** 创建者(用户名) */
|
||||
private String createBy;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 工资快速转出
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "-";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) != ''
|
||||
|
||||
@@ -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, '') <> 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, '') <> 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, '') <> 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, '') <> 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 < 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, '') <> '浙江兰溪农村商业银行股份有限公司'
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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},
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 <= total_asset * 1.5 then 'NORMAL'
|
||||
when comparison_amount > total_asset * 1.5 and comparison_amount <= total_asset * 3 then 'RISK'
|
||||
when comparison_amount > 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 <= total_asset * 1.5 then '正常'
|
||||
when comparison_amount > total_asset * 1.5 and comparison_amount <= total_asset * 3 then '存在风险'
|
||||
when comparison_amount > 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 <= source.total_asset * 1.5 then 1
|
||||
when source.comparison_amount <= source.total_asset * 3 then 2
|
||||
when source.comparison_amount > 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)
|
||||
) <= 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)
|
||||
) <= 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)
|
||||
) > 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
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 最新征信判定与覆盖策略
|
||||
|
||||
系统只保留员工最新征信,规则如下:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`。
|
||||
@@ -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` 观察全量状态并记录非本次问题。
|
||||
@@ -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 已把人员、企业、账号、名称代理做成统一节点键,后续多层追溯可沿用同一套节点键。
|
||||
@@ -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`
|
||||
|
||||
## 实施要点
|
||||
|
||||
- 调整详情弹窗头部、左右分栏比例、页签尺寸和间距。
|
||||
- 调整左侧人物档案与命中模型摘要区块的标题、信息行、风险徽标和标签样式。
|
||||
- 调整右侧异常明细内容区的区块标题、表格头部、单元格留白、异常对象摘要卡片和快照块样式。
|
||||
- 保持现有数据绑定、页签切换、证据库按钮和分页逻辑不变。
|
||||
|
||||
## 验证
|
||||
|
||||
- 在真实业务页面打开项目总览详情弹窗,检查个人详情页视觉是否与参考图一致。
|
||||
- 确认异常明细、对象摘要、加入证据库按钮和分页仍可正常显示。
|
||||
@@ -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 页面。
|
||||
@@ -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`。
|
||||
- 未来可在节点点击事件中调用后端追溯接口,把新增节点和边合并进现有图谱。
|
||||
- 组件已按一层查询和边明细查询拆分,后续追溯不会影响“点边看流水”的核心链路。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 结果总览详情弹窗资产与征信页签前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
在项目详情“结果总览 - 风险总览 - 查看详情”弹窗中,将资产分析和征信页签从占位展示改为真实详情展示。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- `资产分析` 页签复用员工家庭资产负债专项核查中的资产详情展示组件。
|
||||
- `征信摘要` 页签改名为 `征信详情`,复用征信信息维护中的详情展示结构。
|
||||
- 不新增后端接口,不调整后端权限、SQL 或业务口径。
|
||||
|
||||
## 前端改动
|
||||
|
||||
- 抽取征信详情展示组件 `CreditInfoDetail`,并在征信信息维护详情弹窗中复用。
|
||||
- 新增征信详情标准化 helper,以详情接口返回的 `negativeInfo` 和 `debtList` 计算展示字段,不依赖征信列表行。
|
||||
- 在项目分析弹窗中按页签懒加载资产详情和征信详情:
|
||||
- 资产详情调用 `getFamilyAssetLiabilityDetail(projectId, staffIdCard)`。
|
||||
- 征信详情调用 `getCreditInfoDetail(staffIdCard)`。
|
||||
- 同一次打开弹窗内已加载页签不重复请求。
|
||||
- 缺少项目或人员身份证号时只展示页签内提示,不发起请求。
|
||||
- `getCreditInfoDetail` 使用 `encodeURIComponent(personId)` 拼接路径。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 使用 `nvm use` 后运行相关前端静态测试。
|
||||
- 运行前端生产构建验证组件引用与模板编译。
|
||||
- 启动真实前后端后,用浏览器进入项目详情页,在结果总览风险总览中点击“查看详情”,分别验证资产分析和征信详情页签。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 结果总览弹窗资金流向逐笔流水展示前端实施计划
|
||||
|
||||
## 需求范围
|
||||
|
||||
- 修改结果总览“查看详情”弹窗中的“资金流向”页签。
|
||||
- 去掉资金边详情中的“弹窗速览”提示文案。
|
||||
- 在弹窗资金边详情中展示逐笔流水明细,并保持分页加载。
|
||||
- 不修改专项排查页的图谱入口和完整图谱展示逻辑。
|
||||
|
||||
## 实施方案
|
||||
|
||||
1. 调整 `ProjectAnalysisFundFlowTab.vue` 中传给 `FundGraphSection` 的参数,开启资金边逐笔流水表格。
|
||||
2. 保持 `FundGraphSection` 现有边明细接口调用逻辑不变,继续使用分页查询。
|
||||
3. 在弹窗包装组件内改为上方图谱、下方逐笔流水布局,收敛表格和分页样式,避免逐笔流水表撑高或挤压图谱画布。
|
||||
4. 不新增接口、不修改后端、不改变专项排查页完整下钻能力。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 前端构建前按项目规则执行 `nvm use` 并确认 Node 版本。
|
||||
- 执行前端构建或聚焦测试,确认组件编译通过。
|
||||
- 使用真实页面打开结果总览“查看详情”弹窗,切换到“资金流向”,点击资金边确认下方显示逐笔流水和分页。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 结果总览弹窗资金流向可用性优化前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 优化结果总览“查看详情”弹窗内“资金流向”页签的图谱展示空间。
|
||||
- 降低多条资金边金额标签、节点名称在小画布中重叠的问题。
|
||||
- 在查看单个对手方资金边明细后,通过点击图谱画布空白区域恢复全量图谱状态。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- 仅调整结果总览“查看详情”弹窗中的资金流向图谱。
|
||||
- 不调整专项排查页资金图谱的默认尺寸和业务逻辑。
|
||||
- 不修改后端接口、数据库和资金流水分页接口。
|
||||
|
||||
## 实施方案
|
||||
|
||||
1. 在 `ProjectAnalysisFundFlowTab` 中扩大资金流向工作区尺寸:
|
||||
- 提高弹窗内图谱卡片高度。
|
||||
- 改为上方图谱、下方逐笔流水布局,给图谱保留更大画布空间。
|
||||
- 下方逐笔流水表格保持固定高度和分页展示,避免撑高弹窗。
|
||||
|
||||
2. 在 `FundGraphSection` 中增加弹窗可配置能力:
|
||||
- 增加边标签紧凑展示开关,金额使用“万/亿”等短格式展示。
|
||||
- 支持隐藏资金边汇总卡片,只保留逐笔流水明细。
|
||||
- 点击图谱画布空白区域时清空选中节点、选中边和逐笔流水明细,并重新渲染图谱。
|
||||
|
||||
3. 优化选中状态表达:
|
||||
- 图谱中当前边和两端节点保持高亮,其他边降低透明度。
|
||||
- 点空白区域后恢复初始全量图谱状态,不额外增加按钮。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 执行前端构建,确认无编译错误。
|
||||
- 在真实页面进入结果总览“查看详情”弹窗,切换到“资金流向”。
|
||||
- 选择包含多笔交易金额标签的资金边,验证节点名称和金额标签不再严重叠加,逐笔流水显示在图谱下方。
|
||||
- 点击图谱画布空白区域,验证逐笔流水清空,图谱恢复全量状态。
|
||||
609
docs/plans/fullstack/2026-05-28-graph-development-decisions.md
Normal file
609
docs/plans/fullstack/2026-05-28-graph-development-decisions.md
Normal 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. 先落项目内 SQL:DDL、构建 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
|
||||
```
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 2026-05-06 项目分析个人详情页样式对齐实施记录
|
||||
|
||||
## 本次修改
|
||||
|
||||
- 调整 `ProjectAnalysisDialog.vue`,补齐参考图中的标题区、内容区留白、左右分栏间距和页签样式。
|
||||
- 调整 `ProjectAnalysisSidebar.vue`,补齐人物档案区、风险等级徽标、命中模型摘要和标签的正式化版式。
|
||||
- 调整 `ProjectAnalysisAbnormalTab.vue`,补齐流水异常明细表格、异常对象摘要区、快照块和信息行样式。
|
||||
|
||||
## 未改内容
|
||||
|
||||
- 未改接口请求和数据拼装逻辑。
|
||||
- 未改页签切换、分页、加入证据库、异常分组和字段内容。
|
||||
- 未新增或删除业务区块。
|
||||
|
||||
## 验证方式
|
||||
|
||||
- 使用真实业务页面 `http://localhost/ccdiProject/detail/90337?tab=overview` 打开个人详情弹窗进行样式核对。
|
||||
- 核对左侧人物档案、右侧页签、表格块和异常对象摘要块的正式化效果。
|
||||
@@ -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` 端口进程。
|
||||
@@ -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` 前端开发服务。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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` 后点击保存,页面展示强密码校验提示并拦截提交。
|
||||
@@ -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 外部接口兼容逻辑。
|
||||
|
||||
@@ -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`:通过。
|
||||
@@ -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 清理本地状态,因此未继续执行页面上传动作。
|
||||
- 已改用真实后端接口完成上传、落库、日志和清理闭环验证。
|
||||
@@ -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`:通过。
|
||||
@@ -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
Reference in New Issue
Block a user