65 Commits

Author SHA1 Message Date
wkc
979ed9669f 调整测试目录忽略规则 2026-07-02 16:46:57 +08:00
wkc
57a33098c9 实现流程项目逻辑删除与恢复 2026-07-02 10:54:36 +08:00
wkc
2f53fc4d1e 用户角色选择改为下拉单选 2026-07-02 10:53:01 +08:00
wkc
64ddc362e3 新增项目管理员角色权限控制 2026-07-01 17:02:17 +08:00
wkc
6c70149e0c 项目上传删除暂不调用流水分析平台 2026-07-01 17:02:17 +08:00
wkc
2298f9e589 移除风险模型导出按钮 2026-07-01 17:02:17 +08:00
wkc
e08f437acc 为风险明细列表添加分页选择器 2026-07-01 17:00:11 +08:00
wjj
6134ec06af 放开涉疑交易流水级模型命中 2026-07-01 16:58:16 +08:00
wjj
dfa35ebd85 优化涉疑交易模型口径和报告展示 2026-07-01 16:58:16 +08:00
wkc
1f82c7e16e 优化资金图谱主题节点检索 2026-07-01 16:57:31 +08:00
wkc
6c206deb5f 实现结果总览详情资产和征信页签 2026-07-01 16:57:31 +08:00
wkc
2999d7cf7d 调整项目人数按流水证件号统计 2026-07-01 16:55:09 +08:00
wjj
f42981f153 放开涉疑交易流水级模型命中 2026-07-01 16:53:44 +08:00
wkc
67f206be1b Refactor project pages and update related docs 2026-07-01 16:47:26 +08:00
wjj
66d34c7ceb 优化涉疑交易模型口径和报告展示 2026-07-01 16:46:23 +08:00
wkc
2e30b2a877 修正双员工夫妻家庭专项核查口径 2026-07-01 16:37:00 +08:00
wkc
d2cdb0f3c9 修复风险总览无风险人员负数问题 2026-07-01 16:37:00 +08:00
wkc
aaec746181 优化资产估值万元展示 2026-07-01 16:37:00 +08:00
wkc
dcfd869d84 Implement credit parse result polling and sentinel handling 2026-07-01 16:37:00 +08:00
wkc
d284923c59 调整征信解析返回解析和日志 2026-07-01 16:37:00 +08:00
wkc
84b04e31ed Refactor credit parse to use remote HTML paths 2026-07-01 16:37:00 +08:00
wkc
507bc1c2d7 Remove obsolete code and documentation 2026-07-01 16:37:00 +08:00
wkc
64891a621d 移除前端默认登录凭据 2026-07-01 16:37:00 +08:00
wkc
606b4f836b 生产配置 2026-07-01 16:37:00 +08:00
wkc
33c1f9df90 新增生产统一部署脚本 2026-07-01 16:37:00 +08:00
wkc
bee1e800ce Fix PDF font loading for project overview reports 2026-07-01 16:37:00 +08:00
wkc
a191cc7ca3 新增专项排查图谱展示 2026-07-01 16:33:59 +08:00
wkc
7826645a90 新增专项排查图谱前端实施计划 2026-07-01 16:31:35 +08:00
wkc
9b11426617 新增专项排查图谱嵌入设计 2026-07-01 16:31:35 +08:00
wkc
1494491d89 展示员工亲属实体关联统信码 2026-07-01 16:31:35 +08:00
wkc
5509ece708 调整招聘信息毕业年月选择控件 2026-07-01 16:31:35 +08:00
wkc
02e45dadcc 完善招聘信息主键关联与工作经历维护 2026-07-01 16:31:35 +08:00
wkc
caeac0f115 招聘导入模板增加招聘类型下拉框 2026-07-01 16:31:35 +08:00
wkc
a4005d3e9f 修复导入模板格式和必填标记 2026-07-01 16:31:35 +08:00
wkc
f9e0a079f5 Update import templates and relation query fields 2026-07-01 16:31:35 +08:00
wkc
e00d60f5c1 回测四类导入自动补入实体库 2026-07-01 16:31:35 +08:00
wkc
87da17bccb 修复双Sheet资产单独导入任务ID 2026-07-01 16:31:35 +08:00
wkc
4a9c6ba64b 实现关联业务自动补入实体库 2026-07-01 16:31:35 +08:00
wkc
17eb11b3b8 uat配置更新 2026-07-01 16:31:35 +08:00
wkc
b740d4c100 移除.DS_Store跟踪 2026-07-01 16:31:35 +08:00
wkc
f5a98bbd7a 修订: 中介实体补入无需机构名称 2026-07-01 16:31:35 +08:00
wkc
987afc3863 调整信息维护页面并修复项目概览统计 2026-07-01 16:31:35 +08:00
wkc
051650370e 计划: 员工资产导入与实体库自动补入修复 2026-07-01 16:28:16 +08:00
wkc
c211413bd2 修订: 明确资产Sheet单独导入规则 2026-07-01 16:28:16 +08:00
wkc
bb9994a339 修订: 完善员工资产导入与实体库补入设计 2026-07-01 16:28:16 +08:00
wkc
daf463bbb4 设计: 员工资产导入与实体库自动补入修复 2026-07-01 16:28:16 +08:00
wkc
6f6f2d264c 统一项目分析弹窗圆角样式 2026-07-01 16:28:16 +08:00
wkc
266108ac06 修复流水上传原始文件名保持 2026-07-01 16:16:48 +08:00
wkc
5ff67442bb 修复流水分析上传文件名传递 2026-07-01 16:16:48 +08:00
wkc
b7bb96b43b 设计: 保持上传流水原始文件名 2026-07-01 16:16:48 +08:00
wkc
9902430350 Add import dropdown validation 2026-07-01 16:16:48 +08:00
wkc
89773caa7b 新增导入下拉框校验实施计划 2026-07-01 16:16:48 +08:00
wkc
def656d57b 完善导入下拉框校验设计 2026-07-01 16:16:48 +08:00
wkc
6b6fae4b92 新增导入下拉框校验设计文档 2026-07-01 16:16:48 +08:00
wkc
0dc3b6ee2f uat配置文件 2026-07-01 16:16:47 +08:00
wjj
04ede64767 优化项目分析详情与异常明细展示 2026-07-01 11:26:01 +08:00
wjj
066d850389 清洗流水本方证件号格式 2026-06-30 13:40:24 +08:00
wjj
5e4bfca05b 完善外部人员预警与项目分析上线内容 2026-06-30 13:26:29 +08:00
wjj
4e90e22ee2 优化项目详情资金流向图谱展示 2026-06-04 18:01:55 +08:00
wjj
a5eba12ed5 调整流水模型理财剔除与提现规则 2026-06-04 15:55:25 +08:00
wjj
aef6c43181 放开涉疑交易流水级模型命中 2026-06-01 16:44:51 +08:00
wjj
4c2ea9d8e6 修正短时间多次存现本人口径 2026-06-01 15:50:25 +08:00
wjj
26cd049991 新增图谱功能及验收清单 2026-05-29 18:33:26 +08:00
wjj
180a892275 优化涉疑交易模型口径和报告展示 2026-05-26 16:43:39 +08:00
wjj
ef8147892e 补充项目分析个人详情页正式化样式 2026-05-06 16:18:03 +08:00
144 changed files with 8079 additions and 706 deletions

4
.gitignore vendored
View File

@@ -86,12 +86,8 @@ ruoyi-ui/vue.config.js
ruoyi-ui/dist.zip
*/src/test/
.pytest_cache/
tests/
tongweb_62318.properties
.superpowers/

View File

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

View File

@@ -6,6 +6,7 @@ import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -39,13 +40,16 @@ public class CcdiBankStatementController extends BaseController {
@Resource
private ICcdiBankStatementService bankStatementService;
@Resource
private CcdiProjectAccessService projectAccessService;
/**
* 分页查询流水明细
*/
@GetMapping("/list")
@Operation(summary = "分页查询流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public TableDataInfo list(CcdiBankStatementQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiBankStatementListVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiBankStatementListVO> result = bankStatementService.selectStatementPage(page, queryDTO);
@@ -57,8 +61,8 @@ public class CcdiBankStatementController extends BaseController {
*/
@GetMapping("/options")
@Operation(summary = "查询项目级筛选项")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getOptions(Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiBankStatementFilterOptionsVO options = bankStatementService.getFilterOptions(projectId);
return AjaxResult.success(options);
}
@@ -68,8 +72,8 @@ public class CcdiBankStatementController extends BaseController {
*/
@GetMapping("/detail/{bankStatementId}")
@Operation(summary = "查询流水详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getDetail(@PathVariable Long bankStatementId) {
projectAccessService.assertCanReadByBankStatementId(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(bankStatementId);
return AjaxResult.success(detail);
}
@@ -81,6 +85,7 @@ public class CcdiBankStatementController extends BaseController {
@Operation(summary = "导出流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:export')")
public void export(HttpServletResponse response, CcdiBankStatementQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiBankStatementExcel> list = bankStatementService.selectStatementListForExport(queryDTO);
ExcelUtil<CcdiBankStatementExcel> util = new ExcelUtil<>(CcdiBankStatementExcel.class);
util.exportExcel(response, list, "流水明细");

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankTagRebuildDTO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -9,6 +10,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -27,12 +29,17 @@ public class CcdiBankTagController extends BaseController {
@Resource
private ICcdiBankTagService bankTagService;
@Resource
private CcdiProjectAccessService projectAccessService;
/**
* 手动提交流水标签重算任务
*/
@Operation(summary = "手动重算项目流水标签")
@PostMapping("/rebuild")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult rebuild(@Validated @RequestBody CcdiBankTagRebuildDTO dto) {
projectAccessService.assertCanOperate(dto.getProjectId());
String operator = SecurityUtils.getUsername();
log.info("【流水标签】收到手动重算请求: projectId={}, modelCode={}, operator={}",
dto.getProjectId(), dto.getModelCode(), operator);

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceSaveDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiEvidenceVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiEvidenceService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -34,13 +35,17 @@ public class CcdiEvidenceController extends BaseController {
@Resource
private ICcdiEvidenceService evidenceService;
@Resource
private CcdiProjectAccessService projectAccessService;
/**
* 保存证据
*/
@PostMapping
@Operation(summary = "保存证据")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult saveEvidence(@Validated @RequestBody CcdiEvidenceSaveDTO dto) {
projectAccessService.assertCanOperate(dto.getProjectId());
CcdiEvidenceVO vo = evidenceService.saveEvidence(dto, SecurityUtils.getUsername());
return AjaxResult.success("证据入库成功", vo);
}
@@ -50,8 +55,8 @@ public class CcdiEvidenceController extends BaseController {
*/
@GetMapping("/list")
@Operation(summary = "查询项目证据列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult listEvidence(CcdiEvidenceQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiEvidenceVO> list = evidenceService.listEvidence(queryDTO);
return AjaxResult.success(list);
}
@@ -61,8 +66,8 @@ public class CcdiEvidenceController extends BaseController {
*/
@GetMapping("/{evidenceId}")
@Operation(summary = "查询证据详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getEvidence(@PathVariable Long evidenceId) {
projectAccessService.assertCanReadByEvidenceId(evidenceId);
CcdiEvidenceVO vo = evidenceService.getEvidence(evidenceId);
return AjaxResult.success(vo);
}

View File

@@ -6,6 +6,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiPullBankInfoSubmitDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiIdCardParseVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -17,6 +18,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
@@ -40,17 +42,22 @@ public class CcdiFileUploadController extends BaseController {
@Resource
private ICcdiFileUploadService fileUploadService;
@Resource
private CcdiProjectAccessService projectAccessService;
/**
* 批量上传文件(异步)
*/
@PostMapping("/batch")
@Operation(summary = "批量上传文件", description = "异步批量上传流水文件")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult batchUpload(@RequestParam Long projectId,
@RequestParam MultipartFile[] files) {
// 参数校验
if (projectId == null) {
return AjaxResult.error("项目ID不能为空");
}
projectAccessService.assertCanOperate(projectId);
if (files == null || files.length == 0) {
return AjaxResult.error("请选择要上传的文件");
}
@@ -95,6 +102,7 @@ public class CcdiFileUploadController extends BaseController {
*/
@PostMapping("/parse-id-card-file")
@Operation(summary = "解析身份证文件", description = "解析首个sheet第一列的身份证号")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult parseIdCardFile(@RequestParam MultipartFile file) {
if (file == null || file.isEmpty()) {
return AjaxResult.error("身份证文件不能为空");
@@ -108,10 +116,12 @@ public class CcdiFileUploadController extends BaseController {
*/
@PostMapping("/pull-bank-info")
@Operation(summary = "拉取本行信息", description = "按身份证号批量提交拉取本行信息任务")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult pullBankInfo(@RequestBody CcdiPullBankInfoSubmitDTO dto) {
if (dto == null || dto.getProjectId() == null) {
return AjaxResult.error("项目ID不能为空");
}
projectAccessService.assertCanOperate(dto.getProjectId());
if (CollectionUtils.isEmpty(dto.getIdCards())) {
return AjaxResult.error("身份证号不能为空");
}
@@ -138,6 +148,7 @@ public class CcdiFileUploadController extends BaseController {
@GetMapping("/list")
@Operation(summary = "查询上传记录列表", description = "分页查询文件上传记录")
public TableDataInfo list(CcdiFileUploadQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiFileUploadRecord> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiFileUploadRecord> result = fileUploadService.selectPage(page, queryDTO);
@@ -150,6 +161,7 @@ public class CcdiFileUploadController extends BaseController {
@GetMapping("/statistics/{projectId}")
@Operation(summary = "查询上传统计", description = "统计各状态的文件数量")
public AjaxResult getStatistics(@PathVariable Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiFileUploadStatisticsVO statistics = fileUploadService.countByStatus(projectId);
return AjaxResult.success(statistics);
}
@@ -160,6 +172,7 @@ public class CcdiFileUploadController extends BaseController {
@GetMapping("/detail/{id}")
@Operation(summary = "查询记录详情", description = "根据ID查询文件上传记录详情")
public AjaxResult getDetail(@PathVariable Long id) {
projectAccessService.assertCanReadByFileRecordId(id);
CcdiFileUploadRecord record = fileUploadService.getById(id);
return AjaxResult.success(record);
}
@@ -169,7 +182,9 @@ public class CcdiFileUploadController extends BaseController {
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除上传文件", description = "按上传记录ID删除文件并清理流水")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult deleteFile(@PathVariable Long id) {
projectAccessService.assertCanOperateByFileRecordId(id);
Long userId = SecurityUtils.getUserId();
String message = fileUploadService.deleteFileUploadRecord(id, userId);
return AjaxResult.success(message);

View File

@@ -8,6 +8,7 @@ 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.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -38,10 +39,14 @@ public class CcdiFundGraphController extends BaseController {
@Resource
private ICcdiFundGraphService fundGraphService;
@Resource
private CcdiProjectAccessService projectAccessService;
@GetMapping("/search")
@Operation(summary = "查询资金流图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiFundGraphNodeVO> subjects = fundGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@@ -50,6 +55,7 @@ public class CcdiFundGraphController extends BaseController {
@Operation(summary = "查询一层资金流图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiFundGraphQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiFundGraphVO graph = fundGraphService.getFundGraph(queryDTO);
return AjaxResult.success(graph);
}
@@ -58,6 +64,7 @@ public class CcdiFundGraphController extends BaseController {
@Operation(summary = "查询资金边流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public TableDataInfo getEdgeDetail(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiFundGraphStatementVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiFundGraphStatementVO> result = fundGraphService.getEdgeDetails(page, queryDTO);
@@ -66,9 +73,10 @@ public class CcdiFundGraphController extends BaseController {
@PostMapping("/manual-edge")
@Operation(summary = "新增手工资金流向")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult saveManualEdge(@RequestBody CcdiFundGraphManualEdgeSaveDTO saveDTO) {
try {
projectAccessService.assertCanOperate(saveDTO == null ? null : saveDTO.getProjectId());
CcdiFundGraphEdgeVO edge = fundGraphService.saveManualEdge(saveDTO, SecurityUtils.getUsername());
return AjaxResult.success(edge);
} catch (IllegalArgumentException e) {

View File

@@ -11,9 +11,11 @@ import com.ruoyi.ccdi.project.domain.dto.ModelParamSaveAllDTO;
import com.ruoyi.ccdi.project.domain.vo.ModelListVO;
import com.ruoyi.ccdi.project.domain.vo.ModelParamVO;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -31,12 +33,17 @@ public class CcdiModelParamController extends BaseController {
@Resource
private ICcdiModelParamService modelParamService;
@Resource
private CcdiProjectAccessService projectAccessService;
/**
* 查询模型列表
*/
@Operation(summary = "查询模型列表")
@GetMapping("/modelList")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult listModels(@RequestParam(required = false) Long projectId) {
assertCanReadProjectParam(projectId);
List<ModelListVO> list = modelParamService.selectModelList(projectId);
return success(list);
}
@@ -46,7 +53,9 @@ public class CcdiModelParamController extends BaseController {
*/
@Operation(summary = "查询模型参数列表")
@GetMapping("/list")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult list(@Validated ModelParamQueryDTO queryDTO) {
assertCanReadProjectParam(queryDTO.getProjectId());
List<ModelParamVO> list = modelParamService.selectParamList(queryDTO);
return success(list);
}
@@ -57,7 +66,9 @@ public class CcdiModelParamController extends BaseController {
@Operation(summary = "保存模型参数")
@Log(title = "模型参数配置", businessType = BusinessType.UPDATE)
@PostMapping("/save")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult save(@Validated @RequestBody ModelParamSaveDTO saveDTO) {
assertCanOperateProjectParam(saveDTO.getProjectId());
modelParamService.saveParams(saveDTO);
return success("保存成功");
}
@@ -67,7 +78,9 @@ public class CcdiModelParamController extends BaseController {
*/
@Operation(summary = "查询所有模型及其参数")
@GetMapping("/listAll")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult listAll(@Validated ModelParamAllQueryDTO queryDTO) {
assertCanReadProjectParam(queryDTO.getProjectId());
ModelParamAllVO result = modelParamService.selectAllParams(queryDTO.getProjectId());
return success(result);
}
@@ -78,8 +91,24 @@ public class CcdiModelParamController extends BaseController {
@Operation(summary = "批量保存所有模型参数")
@Log(title = "模型参数配置", businessType = BusinessType.UPDATE)
@PostMapping("/saveAll")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult saveAll(@Validated @RequestBody ModelParamSaveAllDTO saveAllDTO) {
assertCanOperateProjectParam(saveAllDTO.getProjectId());
modelParamService.saveAllParams(saveAllDTO);
return success("保存成功");
}
private void assertCanReadProjectParam(Long projectId) {
if (projectId == null || projectId <= 0) {
return;
}
projectAccessService.assertCanRead(projectId);
}
private void assertCanOperateProjectParam(Long projectId) {
if (projectId == null || projectId <= 0) {
return;
}
projectAccessService.assertCanOperate(projectId);
}
}

View File

@@ -74,18 +74,28 @@ public class CcdiProjectController extends BaseController {
*/
@DeleteMapping("/{projectId}")
@Operation(summary = "删除项目")
@PreAuthorize("@ss.hasPermi('ccdi:project:remove')")
@PreAuthorize("@ss.hasPermi('ccdi:project:list')")
public AjaxResult deleteProject(@PathVariable Long projectId) {
boolean success = projectService.deleteProject(projectId);
boolean success = projectService.deleteProject(projectId, SecurityUtils.getUsername());
return success ? AjaxResult.success("项目删除成功") : AjaxResult.error("项目删除失败");
}
/**
* 恢复项目
*/
@PostMapping("/{projectId}/restore")
@Operation(summary = "恢复项目")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult restoreProject(@PathVariable Long projectId) {
projectService.restoreProject(projectId, SecurityUtils.getUsername());
return AjaxResult.success("项目恢复成功");
}
/**
* 查询项目详情
*/
@GetMapping("/{projectId}")
@Operation(summary = "查询项目详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getProject(@PathVariable Long projectId) {
CcdiProjectVO vo = projectService.getProjectById(projectId);
return AjaxResult.success(vo);
@@ -96,7 +106,6 @@ public class CcdiProjectController extends BaseController {
*/
@GetMapping("/list")
@Operation(summary = "查询项目列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:list')")
public TableDataInfo listProject(CcdiProjectQueryDTO queryDTO) {
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiProjectVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
@@ -109,7 +118,6 @@ public class CcdiProjectController extends BaseController {
*/
@GetMapping("/history")
@Operation(summary = "查询历史项目列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:list')")
public AjaxResult listHistoryProjects(CcdiProjectQueryDTO queryDTO) {
List<CcdiProjectHistoryListItemVO> result = projectService.listHistoryProjects(queryDTO);
return AjaxResult.success(result);
@@ -131,7 +139,6 @@ public class CcdiProjectController extends BaseController {
*/
@GetMapping("/statusCounts")
@Operation(summary = "查询项目状态统计")
@PreAuthorize("@ss.hasPermi('ccdi:project:list')")
public AjaxResult getStatusCounts() {
CcdiProjectStatusCountsVO counts = projectService.getStatusCounts();
return AjaxResult.success(counts);

View File

@@ -2,14 +2,20 @@ package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
@@ -17,6 +23,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.controller.BaseController;
@@ -45,6 +52,9 @@ public class CcdiProjectOverviewController extends BaseController {
@Resource
private ICcdiProjectOverviewService overviewService;
@Resource
private CcdiProjectAccessService projectAccessService;
/**
* 查询风险仪表盘
*/
@@ -52,6 +62,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询风险仪表盘")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getDashboard(Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiProjectOverviewDashboardVO dashboard = overviewService.getDashboard(projectId);
return AjaxResult.success(dashboard);
}
@@ -63,10 +74,35 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询风险人员总览")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getRiskPeople(CcdiProjectRiskPeopleQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectRiskPeopleOverviewVO overview = overviewService.getRiskPeopleOverview(queryDTO);
return AjaxResult.success(overview);
}
/**
* 查询外部人员预警
*/
@GetMapping("/external-persons")
@Operation(summary = "查询外部人员预警")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExternalPersons(CcdiProjectExternalPersonQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectExternalPersonWarningVO warnings = overviewService.getExternalPersonWarnings(queryDTO);
return AjaxResult.success(warnings);
}
/**
* 查询外部人员风险汇总
*/
@GetMapping("/external-persons/summary")
@Operation(summary = "查询外部人员风险汇总")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExternalRiskSummary(Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiProjectExternalRiskSummaryVO summary = overviewService.getExternalRiskSummary(projectId);
return AjaxResult.success(summary);
}
/**
* 查询中高风险人员TOP10
*/
@@ -74,6 +110,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询中高风险人员TOP10")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getTopRiskPeople(Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiProjectTopRiskPeopleVO topRiskPeople = overviewService.getTopRiskPeople(projectId);
return AjaxResult.success(topRiskPeople);
}
@@ -85,6 +122,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询风险模型卡片")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getRiskModelCards(Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiProjectRiskModelCardsVO cards = overviewService.getRiskModelCards(projectId);
return AjaxResult.success(cards);
}
@@ -96,10 +134,35 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询风险模型命中人员")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectRiskModelPeopleVO people = overviewService.getRiskModelPeople(queryDTO);
return AjaxResult.success(people);
}
/**
* 查询外部人员风险模型卡片
*/
@GetMapping("/external-risk-models/cards")
@Operation(summary = "查询外部人员风险模型卡片")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExternalRiskModelCards(Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiProjectRiskModelCardsVO cards = overviewService.getExternalRiskModelCards(projectId);
return AjaxResult.success(cards);
}
/**
* 查询外部人员风险模型命中人员
*/
@GetMapping("/external-risk-models/people")
@Operation(summary = "查询外部人员风险模型命中人员")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExternalRiskModelPeople(CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectRiskModelPeopleVO people = overviewService.getExternalRiskModelPeople(queryDTO);
return AjaxResult.success(people);
}
/**
* 查询项目分析详情
*/
@@ -107,6 +170,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询项目分析详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getPersonAnalysisDetail(CcdiProjectPersonAnalysisDetailQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectPersonAnalysisDetailVO detail = overviewService.getPersonAnalysisDetail(queryDTO);
return AjaxResult.success(detail);
}
@@ -118,6 +182,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询涉疑交易明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getSuspiciousTransactions(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectSuspiciousTransactionPageVO pageVO = overviewService.getSuspiciousTransactions(queryDTO);
return AjaxResult.success(pageVO);
}
@@ -129,6 +194,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询项目员工负面征信")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getEmployeeCreditNegative(CcdiProjectEmployeeCreditNegativeQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectEmployeeCreditNegativePageVO pageVO = overviewService.getEmployeeCreditNegative(queryDTO);
return AjaxResult.success(pageVO);
}
@@ -140,6 +206,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "查询异常账户人员信息")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getAbnormalAccountPeople(CcdiProjectAbnormalAccountQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectAbnormalAccountPageVO pageVO = overviewService.getAbnormalAccountPeople(queryDTO);
return AjaxResult.success(pageVO);
}
@@ -154,6 +221,7 @@ public class CcdiProjectOverviewController extends BaseController {
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiProjectSuspiciousTransactionExcel> rows = overviewService.exportSuspiciousTransactions(queryDTO);
ExcelUtil<CcdiProjectSuspiciousTransactionExcel> util =
new ExcelUtil<>(CcdiProjectSuspiciousTransactionExcel.class);
@@ -167,12 +235,58 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "导出风险人员总览")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportRiskPeople(HttpServletResponse response, Long projectId) {
projectAccessService.assertCanRead(projectId);
List<CcdiProjectRiskPeopleOverviewExcel> rows = overviewService.exportRiskPeopleOverview(projectId);
ExcelUtil<CcdiProjectRiskPeopleOverviewExcel> util =
new ExcelUtil<>(CcdiProjectRiskPeopleOverviewExcel.class);
util.exportExcel(response, rows, "风险人员总览");
}
/**
* 导出外部人员预警
*/
@PostMapping("/external-persons/export")
@Operation(summary = "导出外部人员预警")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportExternalPersons(HttpServletResponse response, Long projectId) {
projectAccessService.assertCanRead(projectId);
List<CcdiProjectExternalPersonWarningExcel> rows = overviewService.exportExternalPersonWarnings(projectId);
ExcelUtil<CcdiProjectExternalPersonWarningExcel> util =
new ExcelUtil<>(CcdiProjectExternalPersonWarningExcel.class);
util.exportExcel(response, rows, "外部人员预警");
}
/**
* 导出风险模型命中人员
*/
@PostMapping("/risk-models/people/export")
@Operation(summary = "导出风险模型命中人员")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportRiskModelPeople(HttpServletResponse response, CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiProjectRiskModelPeopleExcel> rows = overviewService.exportRiskModelPeople(queryDTO);
ExcelUtil<CcdiProjectRiskModelPeopleExcel> util =
new ExcelUtil<>(CcdiProjectRiskModelPeopleExcel.class);
util.exportExcel(response, rows, "风险模型命中人员");
}
/**
* 导出外部人员风险模型命中人员
*/
@PostMapping("/external-risk-models/people/export")
@Operation(summary = "导出外部人员风险模型命中人员")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportExternalRiskModelPeople(
HttpServletResponse response,
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiProjectRiskModelPeopleExcel> rows = overviewService.exportExternalRiskModelPeople(queryDTO);
ExcelUtil<CcdiProjectRiskModelPeopleExcel> util =
new ExcelUtil<>(CcdiProjectRiskModelPeopleExcel.class);
util.exportExcel(response, rows, "外部人员风险模型命中人员");
}
/**
* 导出风险明细
*/
@@ -180,6 +294,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "导出风险明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
projectAccessService.assertCanRead(projectId);
overviewService.exportRiskDetails(response, projectId);
}
@@ -190,6 +305,7 @@ public class CcdiProjectOverviewController extends BaseController {
@Operation(summary = "导出结果总览报告")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportOverviewReport(HttpServletResponse response, Long projectId) {
projectAccessService.assertCanRead(projectId);
overviewService.exportOverviewReport(response, projectId);
}
}

View File

@@ -16,6 +16,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedTransferDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedTransferListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityListVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiProjectSpecialCheckService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -39,6 +40,9 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Resource
private ICcdiProjectSpecialCheckService specialCheckService;
@Resource
private CcdiProjectAccessService projectAccessService;
/**
* 查询员工家庭资产负债列表
*/
@@ -46,6 +50,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询员工家庭资产负债列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getFamilyAssetLiabilityList(@Validated CcdiProjectFamilyAssetLiabilityListQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectFamilyAssetLiabilityListVO result = specialCheckService.getFamilyAssetLiabilityList(queryDTO);
return AjaxResult.success(result);
}
@@ -57,6 +62,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询员工家庭资产负债详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getFamilyAssetLiabilityDetail(@Validated CcdiProjectFamilyAssetLiabilityDetailQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectFamilyAssetLiabilityDetailVO result = specialCheckService.getFamilyAssetLiabilityDetail(queryDTO);
return AjaxResult.success(result);
}
@@ -68,6 +74,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询采购拓展列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExtendedPurchaseList(@Validated CcdiProjectExtendedPurchaseQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectExtendedPurchaseListVO result = specialCheckService.getExtendedPurchaseList(queryDTO);
return AjaxResult.success(result);
}
@@ -79,6 +86,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询采购拓展详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExtendedPurchaseDetail(@Validated CcdiProjectExtendedPurchaseDetailQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectExtendedPurchaseDetailVO result = specialCheckService.getExtendedPurchaseDetail(queryDTO);
return AjaxResult.success(result);
}
@@ -90,6 +98,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询招聘拓展列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExtendedRecruitmentList(@Validated CcdiProjectExtendedRecruitmentQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectExtendedRecruitmentListVO result = specialCheckService.getExtendedRecruitmentList(queryDTO);
return AjaxResult.success(result);
}
@@ -101,6 +110,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询招聘拓展详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExtendedRecruitmentDetail(@Validated CcdiProjectExtendedRecruitmentDetailQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectExtendedRecruitmentDetailVO result = specialCheckService.getExtendedRecruitmentDetail(queryDTO);
return AjaxResult.success(result);
}
@@ -112,6 +122,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询调动拓展列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExtendedTransferList(@Validated CcdiProjectExtendedTransferQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectExtendedTransferListVO result = specialCheckService.getExtendedTransferList(queryDTO);
return AjaxResult.success(result);
}
@@ -123,6 +134,7 @@ public class CcdiProjectSpecialCheckController extends BaseController {
@Operation(summary = "查询调动拓展详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExtendedTransferDetail(@Validated CcdiProjectExtendedTransferDetailQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectExtendedTransferDetailVO result = specialCheckService.getExtendedTransferDetail(queryDTO);
return AjaxResult.success(result);
}

View File

@@ -5,6 +5,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQue
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.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -29,10 +30,14 @@ public class CcdiRelationGraphController extends BaseController {
@Resource
private ICcdiRelationGraphService relationGraphService;
@Resource
private CcdiProjectAccessService projectAccessService;
@GetMapping("/search")
@Operation(summary = "查询关系图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiRelationGraphNodeVO> subjects = relationGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@@ -41,6 +46,7 @@ public class CcdiRelationGraphController extends BaseController {
@Operation(summary = "查询一层关系图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiRelationGraphQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiRelationGraphVO graph = relationGraphService.getRelationGraph(queryDTO);
return AjaxResult.success(graph);
}
@@ -49,6 +55,7 @@ public class CcdiRelationGraphController extends BaseController {
@Operation(summary = "查询关系图谱疑似同名企业")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiRelationGraphSuspectedEnterpriseVO result = relationGraphService.getSuspectedEnterprises(queryDTO);
return AjaxResult.success(result);
}

View File

@@ -32,7 +32,7 @@ public class CcdiProject implements Serializable {
/** 配置方式default-全局默认custom-自定义 */
private String configType;
/** 项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败 */
/** 项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败5-已删除 */
private String status;
/** 是否归档0-未归档1-已归档 */
@@ -54,7 +54,7 @@ public class CcdiProject implements Serializable {
private Integer lsfxProjectId;
/** 删除标志0-存在2-删除 */
@TableLogic
@TableLogic(value = "0", delval = "2")
private String delFlag;
/** 创建者 */

View File

@@ -0,0 +1,24 @@
package com.ruoyi.ccdi.project.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 当前登录用户的项目访问范围。
*/
@Data
@AllArgsConstructor
public class ProjectAccessScope {
/** 当前用户名 */
private String username;
/** 是否可查看全部项目 */
private boolean viewAllProjects;
/** 是否超级管理员 */
private boolean superAdmin;
/** 是否项目管理员 */
private boolean projectManager;
}

View File

@@ -40,6 +40,9 @@ public class CcdiBankStatementQueryDTO {
/** 本方主体 */
private List<String> ourSubjects;
/** 本方证件号 */
private List<String> ourCertNos;
/** 本方银行 */
private List<String> ourBanks;

View File

@@ -10,6 +10,9 @@ import java.math.BigDecimal;
@Data
public class CcdiFundGraphManualEdgeSaveDTO {
/** 当前项目ID仅用于写权限校验不参与手工资金流归属过滤 */
private Long projectId;
/** 起点主体object_key为空时默认使用当前查询中心 */
private String fromObjectKey;

View File

@@ -0,0 +1,19 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 外部人员预警查询DTO
*/
@Data
public class CcdiProjectExternalPersonQueryDTO {
/** 项目ID */
private Long projectId;
/** 页码 */
private Integer pageNum;
/** 每页数量 */
private Integer pageSize;
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.ccdi.project.domain.dto;
import java.util.List;
import java.util.stream.Collectors;
import lombok.Data;
/**
* 外部人员模型命中人员查询DTO
*/
@Data
public class CcdiProjectExternalRiskModelPeopleQueryDTO {
/** 项目ID */
private Long projectId;
/** 模型编码 */
private List<String> modelCodes;
/** 匹配方式 */
private String matchMode;
/** 关键字 */
private String keyword;
/** 页码 */
private Integer pageNum;
/** 每页数量 */
private Integer pageSize;
public String getModelCodesCsv() {
if (modelCodes == null || modelCodes.isEmpty()) {
return null;
}
return modelCodes.stream()
.filter(item -> item != null && !item.isBlank())
.map(String::trim)
.distinct()
.collect(Collectors.joining(","));
}
}

View File

@@ -14,4 +14,7 @@ public class CcdiProjectQueryDTO {
/** 项目状态 */
private String status;
/** 是否查询已删除项目列表 */
private Boolean includeDeleted;
}

View File

@@ -8,6 +8,9 @@ import lombok.Data;
@Data
public class CcdiRelationGraphSuspectedEnterpriseQueryDTO {
/** 项目ID */
private Long projectId;
/** 姓名 */
private String personName;

View File

@@ -207,6 +207,7 @@ public class CcdiBankStatement implements Serializable {
entity.setBatchSequence(item.getUploadSequnceNumber());
entity.setCustomerCertNo(item.getCustomerCertNo());
entity.setCustomerSocialCreditCode(item.getCustomerSocialCreditCode());
entity.setCretNo(normalizeCertNo(item.getCretNo()));
// 5. 特殊字段处理
entity.setMetaJson(null); // 根据文档要求强制设为 null
@@ -219,4 +220,19 @@ public class CcdiBankStatement implements Serializable {
throw new RuntimeException("流水数据转换失败", e);
}
}
private static String normalizeCertNo(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim()
.replace('', '-')
.replace('—', '-')
.replace('', '-');
int separatorIndex = normalized.indexOf('-');
if (separatorIndex >= 0) {
normalized = normalized.substring(0, separatorIndex).trim();
}
return normalized.isEmpty() ? null : normalized;
}
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.excel;
import com.ruoyi.common.annotation.Excel;
import lombok.Data;
/**
* 外部人员预警导出对象
*/
@Data
public class CcdiProjectExternalPersonWarningExcel {
@Excel(name = "姓名")
private String name;
@Excel(name = "证件号")
private String idNo;
@Excel(name = "主体类型")
private String subjectType;
@Excel(name = "风险等级")
private String riskLevel;
@Excel(name = "命中模型数")
private Integer modelCount;
@Excel(name = "核心异常点")
private String riskPoint;
@Excel(name = "涉及对象")
private String relatedObject;
@Excel(name = "最近交易时间")
private String latestTradeTime;
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.ccdi.project.domain.excel;
import com.ruoyi.common.annotation.Excel;
import lombok.Data;
/**
* 风险模型命中人员导出对象
*/
@Data
public class CcdiProjectRiskModelPeopleExcel {
@Excel(name = "风险主体")
private String personName;
@Excel(name = "主体类型")
private String subjectType;
@Excel(name = "证件号")
private String idNo;
@Excel(name = "部门/涉及对象")
private String scopeName;
@Excel(name = "命中模型")
private String modelNames;
@Excel(name = "异常标签")
private String hitTags;
}

View File

@@ -14,20 +14,29 @@ public class CcdiProjectSuspiciousTransactionExcel {
@Excel(name = "交易时间")
private String trxDate;
@Excel(name = "可疑人员")
private String suspiciousPersonName;
@Excel(name = "本方账户")
private String leAccountNo;
@Excel(name = "关联人")
private String relatedPersonName;
@Excel(name = "本方主体")
private String leAccountName;
@Excel(name = "对方名称")
private String customerAccountName;
@Excel(name = "对方账户")
private String customerAccountNo;
@Excel(name = "关联员工")
private String relatedStaffDisplay;
@Excel(name = "关系")
private String relationType;
@Excel(name = "摘要")
private String userMemo;
@Excel(name = "摘要/交易类型")
private String summaryAndCashType;
@Excel(name = "交易类型")
private String cashType;
@Excel(name = "异常标签")
private String hitTags;
@Excel(name = "交易金额")
private BigDecimal displayAmount;

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 外部人员预警项
*/
@Data
public class CcdiProjectExternalPersonWarningItemVO {
private String name;
private String idNo;
private String subjectType;
private String riskLevel;
private String riskLevelType;
private Integer riskCount;
private Integer modelCount;
private String riskPoint;
private String relatedObject;
private String latestTradeTime;
private List<CcdiProjectRiskHitTagVO> riskPointTagList;
private String actionLabel;
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 外部人员预警分页
*/
@Data
public class CcdiProjectExternalPersonWarningVO {
private List<CcdiProjectExternalPersonWarningItemVO> rows;
private Long total;
private Long pageNum;
private Long pageSize;
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 外部人员风险等级汇总
*/
@Data
public class CcdiProjectExternalRiskSummaryVO {
private Integer total;
private Integer high;
private Integer medium;
private Integer low;
private Integer noRisk;
}

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.domain.vo;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@@ -21,10 +22,16 @@ public class CcdiProjectOverviewReportVO {
private CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
private CcdiProjectExternalRiskSummaryVO externalRiskSummary = new CcdiProjectExternalRiskSummaryVO();
private List<CcdiProjectOverviewReportModelSummaryVO> modelSummaries = new ArrayList<>();
private List<CcdiProjectOverviewReportModelSummaryVO> externalModelSummaries = new ArrayList<>();
private List<CcdiProjectRiskModelPeopleItemVO> riskPeople = new ArrayList<>();
private List<CcdiProjectExternalPersonWarningExcel> externalPersonWarnings = new ArrayList<>();
private List<CcdiProjectOverviewReportSuspiciousTransactionVO> suspiciousTransactions = new ArrayList<>();
private List<CcdiProjectEmployeeCreditNegativeExcel> illegalPeople = new ArrayList<>();

View File

@@ -13,4 +13,8 @@ public class CcdiProjectOverviewStatVO {
private String label;
private Integer value;
private Integer employeeValue;
private Integer externalValue;
}

View File

@@ -17,4 +17,6 @@ public class CcdiProjectRiskHitTagVO {
private String ruleName;
private String riskLevel;
private String reasonDetail;
}

View File

@@ -26,4 +26,7 @@ public class CcdiProjectStatusCountsVO {
/** 打标失败项目数(状态4) */
private Long status4;
/** 已删除项目数(状态5) */
private Long status5;
}

View File

@@ -61,4 +61,13 @@ public class CcdiProjectVO {
/** 创建者姓名(真实姓名) */
private String createByName;
/** 是否当前用户创建 */
private Boolean ownedByCurrentUser;
/** 当前用户是否可操作 */
private Boolean canOperate;
/** 当前用户是否可删除 */
private Boolean canDelete;
}

View File

@@ -90,6 +90,36 @@ public interface CcdiBankTagAnalysisMapper {
List<BankTagStatementHitVO> selectLargeTransferStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 外部人员单笔大额交易
*
* @param projectId 项目ID
* @param threshold 单笔大额阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectExternalSingleLargeAmountStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 外部人员累计交易超限
*
* @param projectId 项目ID
* @param threshold 累计交易阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectExternalCumulativeTransactionAmountObjects(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 外部人员年流水超限
*
* @param projectId 项目ID
* @param threshold 年流水阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectExternalAnnualTurnoverObjects(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 与客户之间非正常资金往来
*
@@ -126,6 +156,26 @@ public interface CcdiBankTagAnalysisMapper {
*/
List<BankTagStatementHitVO> selectGamblingSensitiveKeywordStatements(@Param("projectId") Long projectId);
/**
* 外部人员疑似赌博摘要
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectExternalGamblingMemoStatements(@Param("projectId") Long projectId);
/**
* 外部人员同日多对手方疑似赌博交易
*
* @param projectId 项目ID
* @param amountMinThreshold 可疑金额下限
* @param amountMaxThreshold 可疑金额上限
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectExternalMultiPartyGamblingTransferObjects(@Param("projectId") Long projectId,
@Param("amountMinThreshold") BigDecimal amountMinThreshold,
@Param("amountMaxThreshold") BigDecimal amountMaxThreshold);
/**
* 特殊金额交易
*
@@ -134,6 +184,22 @@ public interface CcdiBankTagAnalysisMapper {
*/
List<BankTagStatementHitVO> selectSpecialAmountTransactionStatements(@Param("projectId") Long projectId);
/**
* 外部人员与员工或员工亲属交易
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectExternalToStaffOrFamilyTransactionStatements(@Param("projectId") Long projectId);
/**
* 外部人员夜间交易
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectExternalNightTransactionStatements(@Param("projectId") Long projectId);
/**
* 月度固定收入疑似兼职
*

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.ProjectAccessScope;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
@@ -25,7 +26,9 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
* @param queryDTO 查询条件
* @return 分页结果
*/
Page<CcdiProjectVO> selectProjectPage(Page<CcdiProjectVO> page, @Param("queryDTO") CcdiProjectQueryDTO queryDTO);
Page<CcdiProjectVO> selectProjectPage(Page<CcdiProjectVO> page,
@Param("queryDTO") CcdiProjectQueryDTO queryDTO,
@Param("scope") ProjectAccessScope scope);
/**
* 查询历史项目列表
@@ -33,7 +36,34 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
* @param queryDTO 查询条件
* @return 历史项目列表
*/
List<CcdiProjectHistoryListItemVO> selectHistoryProjects(@Param("queryDTO") CcdiProjectQueryDTO queryDTO);
List<CcdiProjectHistoryListItemVO> selectHistoryProjects(@Param("queryDTO") CcdiProjectQueryDTO queryDTO,
@Param("scope") ProjectAccessScope scope);
/**
* 业务逻辑删除项目,仅更新项目主表状态和删除标记。
*
* @param projectId 项目ID
* @param operator 操作人
* @return 更新行数
*/
int markProjectDeleted(@Param("projectId") Long projectId, @Param("operator") String operator);
/**
* 恢复已删除项目为已完成。
*
* @param projectId 项目ID
* @param operator 操作人
* @return 更新行数
*/
int restoreDeletedProject(@Param("projectId") Long projectId, @Param("operator") String operator);
/**
* 统计已删除项目数量。
*
* @param scope 项目访问范围
* @return 已删除项目数量
*/
Long selectDeletedProjectCount(@Param("scope") ProjectAccessScope scope);
/**
* 更新项目总人数与风险人数

View File

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
@@ -11,6 +13,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
@@ -121,6 +125,88 @@ public interface CcdiProjectOverviewMapper {
@Param("query") CcdiProjectRiskModelPeopleQueryDTO query
);
/**
* 查询风险模型命中人员导出列表
*
* @param query 查询条件
* @return 命中人员列表
*/
List<CcdiProjectRiskModelPeopleItemVO> selectRiskModelPeopleList(
@Param("query") CcdiProjectRiskModelPeopleQueryDTO query
);
/**
* 分页查询外部人员预警
*
* @param page 分页参数
* @param query 查询条件
* @return 外部人员预警分页
*/
Page<CcdiProjectExternalPersonWarningItemVO> selectExternalPersonWarningPage(
Page<CcdiProjectExternalPersonWarningItemVO> page,
@Param("query") CcdiProjectExternalPersonQueryDTO query
);
/**
* 查询外部人员预警导出列表
*
* @param projectId 项目ID
* @return 外部人员预警列表
*/
List<CcdiProjectExternalPersonWarningItemVO> selectExternalPersonWarningList(@Param("projectId") Long projectId);
/**
* 查询外部人员风险等级汇总
*
* @param projectId 项目ID
* @return 外部人员风险等级汇总
*/
CcdiProjectExternalRiskSummaryVO selectExternalRiskSummaryByProjectId(@Param("projectId") Long projectId);
/**
* 查询外部人员预警模型卡片
*
* @param projectId 项目ID
* @return 模型卡片
*/
List<CcdiProjectRiskModelCardVO> selectExternalRiskModelCardsByProjectId(@Param("projectId") Long projectId);
/**
* 分页查询外部人员模型命中人员
*
* @param page 分页参数
* @param query 查询条件
* @return 命中人员分页
*/
Page<CcdiProjectRiskModelPeopleItemVO> selectExternalRiskModelPeoplePage(
Page<CcdiProjectRiskModelPeopleItemVO> page,
@Param("query") CcdiProjectExternalRiskModelPeopleQueryDTO query
);
/**
* 查询外部人员模型命中人员导出列表
*
* @param query 查询条件
* @return 命中人员列表
*/
List<CcdiProjectRiskModelPeopleItemVO> selectExternalRiskModelPeopleList(
@Param("query") CcdiProjectExternalRiskModelPeopleQueryDTO query
);
/**
* 查询外部人员命中标签
*
* @param projectId 项目ID
* @param certNo 外部人员证件号
* @param selectedModelCodes 已选模型编码CSV可为空
* @return 命中标签列表
*/
List<CcdiProjectRiskHitTagVO> selectExternalRiskHitTagsByScope(
@Param("projectId") Long projectId,
@Param("certNo") String certNo,
@Param("selectedModelCodes") String selectedModelCodes
);
/**
* 分页查询涉疑交易明细
*
@@ -240,4 +326,5 @@ public interface CcdiProjectOverviewMapper {
* @return 风险人数汇总
*/
Map<String, Object> selectRiskCountSummaryByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -0,0 +1,213 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.ProjectAccessScope;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
import com.ruoyi.ccdi.project.domain.entity.CcdiEvidence;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiEvidenceMapper;
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.common.core.domain.entity.SysRole;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.List;
import java.util.Objects;
/**
* 项目访问控制。
*/
@Service
public class CcdiProjectAccessService {
private static final String ROLE_ADMIN = "admin";
private static final String ROLE_MANAGER = "manager";
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiFileUploadRecordMapper fileUploadRecordMapper;
@Resource
private CcdiEvidenceMapper evidenceMapper;
public ProjectAccessScope buildCurrentScope() {
LoginUser loginUser = SecurityUtils.getLoginUser();
String username = SecurityUtils.getUsername();
boolean superAdmin = isSuperAdmin(loginUser);
boolean projectManager = hasRole(loginUser, ROLE_MANAGER);
return new ProjectAccessScope(username, superAdmin || projectManager, superAdmin, projectManager);
}
public boolean canOperate(CcdiProject project) {
if (project == null) {
return false;
}
ProjectAccessScope scope = buildCurrentScope();
return scope.isSuperAdmin() || Objects.equals(scope.getUsername(), project.getCreateBy());
}
public boolean canDelete(CcdiProject project) {
if (project == null) {
return false;
}
ProjectAccessScope scope = buildCurrentScope();
return isProjectAdmin(scope) || Objects.equals(scope.getUsername(), project.getCreateBy());
}
public void assertCanRead(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
assertProjectNotDeleted(project);
ProjectAccessScope scope = buildCurrentScope();
if (scope.isViewAllProjects() || Objects.equals(scope.getUsername(), project.getCreateBy())) {
return;
}
throw new ServiceException("无权查看该项目");
}
public void assertCanOperate(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
assertProjectNotDeleted(project);
if (canOperate(project)) {
return;
}
throw new ServiceException("无权操作该项目");
}
public void assertCanDelete(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
assertProjectNotDeleted(project);
if (canDelete(project)) {
return;
}
throw new ServiceException("无权删除该项目");
}
public void assertCanManageDeletedProjects() {
if (isProjectAdmin(buildCurrentScope())) {
return;
}
throw new ServiceException("无权管理已删除项目");
}
public void assertCanReadByBankStatementId(Long bankStatementId) {
CcdiBankStatement statement = getRequiredBankStatement(bankStatementId);
assertCanRead(statement.getProjectId());
}
public void assertCanReadByFileRecordId(Long fileRecordId) {
CcdiFileUploadRecord record = getRequiredFileRecord(fileRecordId);
assertCanRead(record.getProjectId());
}
public void assertCanOperateByFileRecordId(Long fileRecordId) {
CcdiFileUploadRecord record = getRequiredFileRecord(fileRecordId);
assertCanOperate(record.getProjectId());
}
public void assertCanReadByEvidenceId(Long evidenceId) {
CcdiEvidence evidence = getRequiredEvidence(evidenceId);
assertCanRead(evidence.getProjectId());
}
public void assertSourceProjectsReadable(List<Long> sourceProjectIds) {
if (CollectionUtils.isEmpty(sourceProjectIds)) {
return;
}
for (Long sourceProjectId : sourceProjectIds) {
assertCanRead(sourceProjectId);
}
}
private CcdiProject getRequiredProject(Long projectId) {
if (projectId == null) {
throw new ServiceException("项目ID不能为空");
}
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
return project;
}
private void assertProjectNotDeleted(CcdiProject project) {
if (project == null) {
return;
}
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus()) || "2".equals(project.getDelFlag())) {
throw new ServiceException("项目已删除");
}
}
private boolean isProjectAdmin(ProjectAccessScope scope) {
return scope != null && (scope.isSuperAdmin() || scope.isProjectManager());
}
private CcdiBankStatement getRequiredBankStatement(Long bankStatementId) {
if (bankStatementId == null) {
throw new ServiceException("流水ID不能为空");
}
CcdiBankStatement statement = bankStatementMapper.selectById(bankStatementId);
if (statement == null) {
throw new ServiceException("流水记录不存在");
}
return statement;
}
private CcdiFileUploadRecord getRequiredFileRecord(Long fileRecordId) {
if (fileRecordId == null) {
throw new ServiceException("文件记录ID不能为空");
}
CcdiFileUploadRecord record = fileUploadRecordMapper.selectById(fileRecordId);
if (record == null) {
throw new ServiceException("文件记录不存在");
}
return record;
}
private CcdiEvidence getRequiredEvidence(Long evidenceId) {
if (evidenceId == null) {
throw new ServiceException("证据ID不能为空");
}
CcdiEvidence evidence = evidenceMapper.selectById(evidenceId);
if (evidence == null) {
throw new ServiceException("证据不存在");
}
return evidence;
}
private boolean isSuperAdmin(LoginUser loginUser) {
if (loginUser == null) {
return false;
}
if (SecurityUtils.isAdmin(loginUser.getUserId())) {
return true;
}
return hasRole(loginUser, ROLE_ADMIN);
}
private boolean hasRole(LoginUser loginUser, String roleKey) {
if (loginUser == null || loginUser.getUser() == null
|| CollectionUtils.isEmpty(loginUser.getUser().getRoles())) {
return false;
}
for (SysRole role : loginUser.getUser().getRoles()) {
if (roleKey.equals(role.getRoleKey())) {
return true;
}
}
return false;
}
}

View File

@@ -2,16 +2,22 @@ package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
@@ -82,6 +88,80 @@ public interface ICcdiProjectOverviewService {
return new CcdiProjectRiskModelPeopleVO();
}
/**
* 导出风险模型命中人员
*
* @param queryDTO 查询条件
* @return 导出列表
*/
default List<CcdiProjectRiskModelPeopleExcel> exportRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
return List.of();
}
/**
* 查询外部人员预警
*
* @param queryDTO 查询条件
* @return 外部人员预警
*/
default CcdiProjectExternalPersonWarningVO getExternalPersonWarnings(CcdiProjectExternalPersonQueryDTO queryDTO) {
return new CcdiProjectExternalPersonWarningVO();
}
/**
* 查询外部人员风险等级汇总
*
* @param projectId 项目ID
* @return 外部人员风险等级汇总
*/
default CcdiProjectExternalRiskSummaryVO getExternalRiskSummary(Long projectId) {
return new CcdiProjectExternalRiskSummaryVO();
}
/**
* 导出外部人员预警
*
* @param projectId 项目ID
* @return 导出列表
*/
default List<CcdiProjectExternalPersonWarningExcel> exportExternalPersonWarnings(Long projectId) {
return List.of();
}
/**
* 查询外部人员风险模型卡片
*
* @param projectId 项目ID
* @return 风险模型卡片
*/
default CcdiProjectRiskModelCardsVO getExternalRiskModelCards(Long projectId) {
return new CcdiProjectRiskModelCardsVO();
}
/**
* 查询外部人员风险模型命中人员
*
* @param queryDTO 查询条件
* @return 命中人员
*/
default CcdiProjectRiskModelPeopleVO getExternalRiskModelPeople(
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
) {
return new CcdiProjectRiskModelPeopleVO();
}
/**
* 导出外部人员风险模型命中人员
*
* @param queryDTO 查询条件
* @return 导出列表
*/
default List<CcdiProjectRiskModelPeopleExcel> exportExternalRiskModelPeople(
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
) {
return List.of();
}
/**
* 查询涉疑交易明细
*

View File

@@ -36,9 +36,18 @@ public interface ICcdiProjectService {
* 删除项目
*
* @param projectId 项目ID
* @param operator 操作人
* @return 是否成功
*/
boolean deleteProject(Long projectId);
boolean deleteProject(Long projectId, String operator);
/**
* 恢复已删除项目
*
* @param projectId 项目ID
* @param operator 操作人
*/
void restoreProject(Long projectId, String operator);
/**
* 查询项目详情

View File

@@ -27,10 +27,13 @@ public class BankTagRuleConfigResolver {
private static final Map<String, Set<String>> RULE_PARAM_MAPPING = Map.ofEntries(
Map.entry("SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT")),
Map.entry("CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT")),
Map.entry("EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT", Set.of("CUMULATIVE_TRANSACTION_AMOUNT")),
Map.entry("ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER")),
Map.entry("EXTERNAL_ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER")),
Map.entry("LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT")),
Map.entry("FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT")),
Map.entry("LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER")),
Map.entry("EXTERNAL_SINGLE_LARGE_AMOUNT", Set.of("FREQUENT_TRANSFER")),
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")),
@@ -38,10 +41,18 @@ public class BankTagRuleConfigResolver {
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")),
Map.entry("EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),
Map.entry("MONTHLY_FIXED_INCOME", Set.of("MONTHLY_FIXED_INCOME")),
Map.entry("FIXED_COUNTERPARTY_TRANSFER", Set.of("FIXED_COUNTERPARTY_TRANSFER_MIN", "FIXED_COUNTERPARTY_TRANSFER_MAX"))
);
private static final Map<String, String> RULE_PARAM_MODEL_MAPPING = Map.of(
"EXTERNAL_SINGLE_LARGE_AMOUNT", "LARGE_TRANSACTION",
"EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT", "LARGE_TRANSACTION",
"EXTERNAL_ANNUAL_TURNOVER", "LARGE_TRANSACTION",
"EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER", "SUSPICIOUS_GAMBLING"
);
@Resource
private CcdiProjectMapper projectMapper;
@@ -69,12 +80,13 @@ public class BankTagRuleConfigResolver {
}
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : projectId;
List<CcdiModelParam> params = modelParamMapper.selectByProjectAndModel(effectiveProjectId, ruleMeta.getModelCode());
String paramModelCode = RULE_PARAM_MODEL_MAPPING.getOrDefault(ruleMeta.getRuleCode(), ruleMeta.getModelCode());
List<CcdiModelParam> params = modelParamMapper.selectByProjectAndModel(effectiveProjectId, paramModelCode);
Map<String, String> thresholdValues = new LinkedHashMap<>();
Set<String> requiredParamCodes = RULE_PARAM_MAPPING.getOrDefault(ruleMeta.getRuleCode(), Set.of());
log.info("【流水标签】解析规则参数: projectId={}, effectiveProjectId={}, ruleCode={}, requiredParams={}",
projectId, effectiveProjectId, ruleMeta.getRuleCode(), requiredParamCodes);
log.info("【流水标签】解析规则参数: projectId={}, effectiveProjectId={}, ruleCode={}, paramModelCode={}, requiredParams={}",
projectId, effectiveProjectId, ruleMeta.getRuleCode(), paramModelCode, requiredParamCodes);
for (CcdiModelParam param : params) {
if (requiredParamCodes.contains(param.getParamCode())) {
thresholdValues.put(param.getParamCode(), param.getParamValue());

View File

@@ -225,9 +225,15 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "LARGE_TRANSFER" -> analysisMapper.selectLargeTransferStatements(
projectId, toBigDecimal(config.getThresholdValue("FREQUENT_TRANSFER"))
);
case "EXTERNAL_SINGLE_LARGE_AMOUNT" -> analysisMapper.selectExternalSingleLargeAmountStatements(
projectId, toBigDecimal(config.getThresholdValue("FREQUENT_TRANSFER"))
);
case "ABNORMAL_CUSTOMER_TRANSACTION" -> analysisMapper.selectAbnormalCustomerTransactionStatements(projectId);
case "EXTERNAL_NIGHT_TRANSACTION" -> analysisMapper.selectExternalNightTransactionStatements(projectId);
case "GAMBLING_SENSITIVE_KEYWORD" -> analysisMapper.selectGamblingSensitiveKeywordStatements(projectId);
case "EXTERNAL_GAMBLING_MEMO" -> analysisMapper.selectExternalGamblingMemoStatements(projectId);
case "SPECIAL_AMOUNT_TRANSACTION" -> analysisMapper.selectSpecialAmountTransactionStatements(projectId);
case "EXTERNAL_TO_STAFF_FAMILY_TRANSACTION" -> analysisMapper.selectExternalToStaffOrFamilyTransactionStatements(projectId);
case "SUSPICIOUS_INCOME_KEYWORD" -> analysisMapper.selectSuspiciousIncomeKeywordStatements(projectId);
case "HOUSE_REGISTRATION_MISMATCH" -> analysisMapper.selectHouseRegistrationMismatchStatements(projectId);
case "PROPERTY_FEE_REGISTRATION_MISMATCH" -> analysisMapper.selectPropertyFeeRegistrationMismatchStatements(projectId);
@@ -258,9 +264,15 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "CUMULATIVE_INCOME" -> analysisMapper.selectCumulativeIncomeObjects(
projectId, toBigDecimal(config.getThresholdValue("CUMULATIVE_TRANSACTION_AMOUNT"))
);
case "EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT" -> analysisMapper.selectExternalCumulativeTransactionAmountObjects(
projectId, toBigDecimal(config.getThresholdValue("CUMULATIVE_TRANSACTION_AMOUNT"))
);
case "ANNUAL_TURNOVER" -> analysisMapper.selectAnnualTurnoverObjects(
projectId, toBigDecimal(config.getThresholdValue("ANNUAL_TURNOVER"))
);
case "EXTERNAL_ANNUAL_TURNOVER" -> analysisMapper.selectExternalAnnualTurnoverObjects(
projectId, toBigDecimal(config.getThresholdValue("ANNUAL_TURNOVER"))
);
case "FREQUENT_CASH_DEPOSIT" -> analysisMapper.selectFrequentCashDepositObjects(
projectId,
toBigDecimal(config.getThresholdValue("LARGE_CASH_DEPOSIT")),
@@ -272,6 +284,11 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MIN")),
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MAX"))
);
case "EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER" -> analysisMapper.selectExternalMultiPartyGamblingTransferObjects(
projectId,
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MIN")),
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MAX"))
);
case "MONTHLY_FIXED_INCOME" -> analysisMapper.selectMonthlyFixedIncomeObjects(
projectId, toBigDecimal(config.getThresholdValue("MONTHLY_FIXED_INCOME"))
);

View File

@@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
@@ -65,7 +66,7 @@ public class CcdiProjectOverviewReportPdfExporter {
writeCover(writer, report);
writeUploadSubjects(writer, report.getUploadSubjects());
writeParams(writer, report.getParams());
writeRiskModels(writer, report);
writeRiskOverview(writer, report);
writeRiskDetails(writer, report);
writer.close();
document.save(response.getOutputStream());
@@ -118,9 +119,9 @@ public class CcdiProjectOverviewReportPdfExporter {
);
}
private void writeRiskModels(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.section("三、风险模型");
writer.metrics(report.getDashboard().getStats());
private void writeRiskOverview(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.section("三、风险总览");
writer.metrics(buildOverallRiskMetrics(report));
writer.subsection("风险模型汇总");
writer.table(
List.of("模型名称", "预警数量", "涉及人员"),
@@ -151,6 +152,40 @@ public class CcdiProjectOverviewReportPdfExporter {
new float[] { 0.1F, 0.11F, 0.16F, 0.14F, 0.24F, 0.25F },
"暂无风险人员与异常点数据"
);
if (hasExternalRisk(report)) {
writer.subsection("外部人员预警");
writer.metrics(buildExternalMetrics(report));
writer.table(
List.of("外部模型", "预警数量", "涉及人数"),
report.getExternalModelSummaries().stream()
.map(item -> List.of(
safeText(item.getModelName()),
String.valueOf(defaultZero(item.getWarningCount())),
formatCount(item.getPeopleCount(), "")
))
.collect(Collectors.toList()),
new float[] { 0.5F, 0.2F, 0.3F },
"暂无外部人员模型汇总数据"
);
writer.table(
List.of("姓名", "证件号", "主体类型", "风险等级", "命中模型数", "核心异常点", "涉及对象", "最近交易时间"),
report.getExternalPersonWarnings().stream()
.map(item -> List.of(
safeText(item.getName()),
maskIdCard(item.getIdNo()),
safeText(item.getSubjectType()),
safeText(item.getRiskLevel()),
String.valueOf(defaultZero(item.getModelCount())),
safeText(item.getRiskPoint()),
safeText(item.getRelatedObject()),
safeText(item.getLatestTradeTime())
))
.collect(Collectors.toList()),
new float[] { 0.09F, 0.15F, 0.1F, 0.09F, 0.1F, 0.25F, 0.12F, 0.1F },
"暂无外部人员预警数据"
);
}
}
private void writeRiskDetails(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
@@ -328,6 +363,69 @@ public class CcdiProjectOverviewReportPdfExporter {
return result;
}
private List<CcdiProjectOverviewStatVO> buildOverallRiskMetrics(CcdiProjectOverviewReportVO report) {
List<CcdiProjectOverviewStatVO> employeeStats = report.getDashboard().getStats();
if (employeeStats == null || employeeStats.isEmpty()) {
return List.of(
buildMetric("总人数", report.getExternalRiskSummary().getTotal()),
buildMetric("高风险", report.getExternalRiskSummary().getHigh()),
buildMetric("中风险", report.getExternalRiskSummary().getMedium()),
buildMetric("低风险", report.getExternalRiskSummary().getLow()),
buildMetric("无风险", report.getExternalRiskSummary().getNoRisk())
);
}
int employeeTotal = metricValue(employeeStats, "people");
int high = metricValue(employeeStats, "riskPeople");
int medium = metricValue(employeeStats, "medium");
int low = metricValue(employeeStats, "low");
int noRisk = metricValue(employeeStats, "count");
int externalTotal = defaultZero(report.getExternalRiskSummary().getTotal());
int externalHigh = defaultZero(report.getExternalRiskSummary().getHigh());
int externalMedium = defaultZero(report.getExternalRiskSummary().getMedium());
int externalLow = defaultZero(report.getExternalRiskSummary().getLow());
int externalNoRisk = defaultZero(report.getExternalRiskSummary().getNoRisk());
return List.of(
buildMetric("总人数", employeeTotal + externalTotal),
buildMetric("高风险", high + externalHigh),
buildMetric("中风险", medium + externalMedium),
buildMetric("低风险", low + externalLow),
buildMetric("无风险", noRisk + externalNoRisk)
);
}
private Integer metricValue(List<CcdiProjectOverviewStatVO> stats, String key) {
return defaultZero(stats.stream()
.filter(stat -> key.equals(stat.getKey()))
.findFirst()
.map(CcdiProjectOverviewStatVO::getValue)
.orElse(0));
}
private boolean hasExternalRisk(CcdiProjectOverviewReportVO report) {
return defaultZero(report.getExternalRiskSummary().getHigh()) > 0
|| defaultZero(report.getExternalRiskSummary().getMedium()) > 0
|| defaultZero(report.getExternalRiskSummary().getLow()) > 0
|| !report.getExternalModelSummaries().isEmpty()
|| !report.getExternalPersonWarnings().isEmpty();
}
private List<CcdiProjectOverviewStatVO> buildExternalMetrics(CcdiProjectOverviewReportVO report) {
return List.of(
buildMetric("外部人员", report.getExternalRiskSummary().getTotal()),
buildMetric("高风险", report.getExternalRiskSummary().getHigh()),
buildMetric("中风险", report.getExternalRiskSummary().getMedium()),
buildMetric("低风险", report.getExternalRiskSummary().getLow()),
buildMetric("无风险人员", report.getExternalRiskSummary().getNoRisk())
);
}
private CcdiProjectOverviewStatVO buildMetric(String label, Integer value) {
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
stat.setLabel(label);
stat.setValue(defaultZero(value));
return stat;
}
private String formatPeopleSummary(CcdiProjectOverviewReportModelSummaryVO item) {
String names = safeText(item.getPeopleNames());
if ("-".equals(names)) {

View File

@@ -1,15 +1,20 @@
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;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
@@ -25,27 +30,33 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisAbnormalGroupVO
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
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;
@@ -58,7 +69,6 @@ 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;
@@ -79,6 +89,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiModelParamMapper modelParamMapper;
@Resource
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
@@ -94,10 +107,6 @@ 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);
@@ -218,6 +227,108 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return people;
}
@Override
public List<CcdiProjectRiskModelPeopleExcel> exportRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
ensureProjectExists(queryDTO.getProjectId());
normalizeRiskModelPeopleQuery(queryDTO);
return defaultList(overviewMapper.selectRiskModelPeopleList(queryDTO)).stream()
.map(item -> buildRiskModelPeopleExcelRow(item, "员工"))
.toList();
}
@Override
public CcdiProjectExternalPersonWarningVO getExternalPersonWarnings(CcdiProjectExternalPersonQueryDTO queryDTO) {
ensureProjectExists(queryDTO.getProjectId());
Page<CcdiProjectExternalPersonWarningItemVO> page = new Page<>(
defaultRiskPeoplePageNum(queryDTO.getPageNum()),
defaultRiskPeoplePageSize(queryDTO.getPageSize())
);
Page<CcdiProjectExternalPersonWarningItemVO> resultPage =
overviewMapper.selectExternalPersonWarningPage(page, queryDTO);
List<CcdiProjectExternalPersonWarningItemVO> rows =
defaultList(resultPage == null ? null : resultPage.getRecords()).stream()
.peek(item -> item.setActionLabel(ACTION_LABEL))
.toList();
CcdiProjectExternalPersonWarningVO warnings = new CcdiProjectExternalPersonWarningVO();
warnings.setRows(rows);
warnings.setTotal(resultPage == null ? 0L : resultPage.getTotal());
warnings.setPageNum(page.getCurrent());
warnings.setPageSize(page.getSize());
return warnings;
}
@Override
public CcdiProjectExternalRiskSummaryVO getExternalRiskSummary(Long projectId) {
ensureProjectExists(projectId);
CcdiProjectExternalRiskSummaryVO summary = overviewMapper.selectExternalRiskSummaryByProjectId(projectId);
if (summary == null) {
return new CcdiProjectExternalRiskSummaryVO();
}
summary.setTotal(defaultZero(summary.getTotal()));
summary.setHigh(defaultZero(summary.getHigh()));
summary.setMedium(defaultZero(summary.getMedium()));
summary.setLow(defaultZero(summary.getLow()));
summary.setNoRisk(defaultZero(summary.getNoRisk()));
return summary;
}
@Override
public List<CcdiProjectExternalPersonWarningExcel> exportExternalPersonWarnings(Long projectId) {
ensureProjectExists(projectId);
return defaultList(overviewMapper.selectExternalPersonWarningList(projectId)).stream()
.map(this::buildExternalPersonWarningExcelRow)
.toList();
}
@Override
public CcdiProjectRiskModelCardsVO getExternalRiskModelCards(Long projectId) {
ensureProjectExists(projectId);
CcdiProjectRiskModelCardsVO cards = new CcdiProjectRiskModelCardsVO();
cards.setCardList(defaultList(overviewMapper.selectExternalRiskModelCardsByProjectId(projectId)));
return cards;
}
@Override
public CcdiProjectRiskModelPeopleVO getExternalRiskModelPeople(CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO) {
ensureProjectExists(queryDTO.getProjectId());
normalizeExternalRiskModelPeopleQuery(queryDTO);
Page<CcdiProjectRiskModelPeopleItemVO> page = new Page<>(
defaultPageNum(queryDTO.getPageNum()),
defaultPageSize(queryDTO.getPageSize())
);
Page<CcdiProjectRiskModelPeopleItemVO> resultPage =
overviewMapper.selectExternalRiskModelPeoplePage(page, queryDTO);
List<CcdiProjectRiskModelPeopleItemVO> rows = defaultList(resultPage == null ? null : resultPage.getRecords())
.stream()
.peek(item -> item.setActionLabel(ACTION_LABEL))
.toList();
CcdiProjectRiskModelPeopleVO people = new CcdiProjectRiskModelPeopleVO();
people.setRows(rows);
people.setTotal(resultPage == null ? 0L : resultPage.getTotal());
return people;
}
@Override
public List<CcdiProjectRiskModelPeopleExcel> exportExternalRiskModelPeople(
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
) {
ensureProjectExists(queryDTO.getProjectId());
normalizeExternalRiskModelPeopleQuery(queryDTO);
return defaultList(overviewMapper.selectExternalRiskModelPeopleList(queryDTO)).stream()
.map(item -> buildRiskModelPeopleExcelRow(item, item.getStaffCode()))
.toList();
}
@Override
public CcdiProjectSuspiciousTransactionPageVO getSuspiciousTransactions(
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
@@ -245,7 +356,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
ensureProjectExists(queryDTO.getProjectId());
normalizeSuspiciousTransactionQuery(queryDTO);
return defaultList(overviewMapper.selectSuspiciousTransactionList(queryDTO)).stream()
return defaultList(overviewMapper.selectReportSuspiciousTransactionList(queryDTO)).stream()
.map(this::buildSuspiciousTransactionExcelRow)
.toList();
}
@@ -334,11 +445,14 @@ 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(projectId));
report.setParams(buildReportParams(project));
report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId)));
report.setExternalRiskSummary(getExternalRiskSummary(projectId));
report.setExternalModelSummaries(buildExternalReportModelSummaries(projectId));
report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream()
.peek(item -> item.setActionLabel(ACTION_LABEL))
.toList());
report.setExternalPersonWarnings(exportExternalPersonWarnings(projectId));
report.setSuspiciousTransactions(defaultList(
overviewMapper.selectReportSuspiciousTransactionList(suspiciousQuery)
));
@@ -445,6 +559,51 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row;
}
private CcdiProjectExternalPersonWarningExcel buildExternalPersonWarningExcelRow(
CcdiProjectExternalPersonWarningItemVO item
) {
CcdiProjectExternalPersonWarningExcel row = new CcdiProjectExternalPersonWarningExcel();
row.setName(item.getName());
row.setIdNo(item.getIdNo());
row.setSubjectType(item.getSubjectType());
row.setRiskLevel(item.getRiskLevel());
row.setModelCount(item.getModelCount());
row.setRiskPoint(item.getRiskPoint());
row.setRelatedObject(item.getRelatedObject());
row.setLatestTradeTime(item.getLatestTradeTime());
return row;
}
private List<CcdiProjectOverviewReportModelSummaryVO> buildExternalReportModelSummaries(Long projectId) {
return defaultList(overviewMapper.selectExternalRiskModelCardsByProjectId(projectId)).stream()
.map(this::buildExternalReportModelSummary)
.toList();
}
private CcdiProjectOverviewReportModelSummaryVO buildExternalReportModelSummary(CcdiProjectRiskModelCardVO card) {
CcdiProjectOverviewReportModelSummaryVO row = new CcdiProjectOverviewReportModelSummaryVO();
row.setModelCode(card.getModelCode());
row.setModelName(card.getModelName());
row.setWarningCount(card.getWarningCount());
row.setPeopleCount(card.getPeopleCount());
row.setPeopleNames("-");
return row;
}
private CcdiProjectRiskModelPeopleExcel buildRiskModelPeopleExcelRow(
CcdiProjectRiskModelPeopleItemVO item,
String subjectType
) {
CcdiProjectRiskModelPeopleExcel row = new CcdiProjectRiskModelPeopleExcel();
row.setPersonName(item.getStaffName());
row.setSubjectType(subjectType);
row.setIdNo(item.getIdNo());
row.setScopeName(item.getDepartment());
row.setModelNames(joinModelNames(item.getModelNames()));
row.setHitTags(joinHitTagNames(item.getHitTagList()));
return row;
}
private void ensureProjectExists(Long projectId) {
getRequiredProject(projectId);
}
@@ -457,6 +616,14 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
queryDTO.setMatchMode(queryDTO.getMatchMode().trim().toUpperCase());
}
private void normalizeExternalRiskModelPeopleQuery(CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO) {
if (queryDTO.getMatchMode() == null || queryDTO.getMatchMode().isBlank()) {
queryDTO.setMatchMode("ANY");
return;
}
queryDTO.setMatchMode(queryDTO.getMatchMode().trim().toUpperCase());
}
private void normalizeSuspiciousTransactionQuery(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (queryDTO.getSuspiciousType() == null || queryDTO.getSuspiciousType().isBlank()) {
queryDTO.setSuspiciousType("ALL");
@@ -526,15 +693,18 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
}
private CcdiProjectSuspiciousTransactionExcel buildSuspiciousTransactionExcelRow(
CcdiProjectSuspiciousTransactionItemVO item
CcdiProjectOverviewReportSuspiciousTransactionVO item
) {
CcdiProjectSuspiciousTransactionExcel row = new CcdiProjectSuspiciousTransactionExcel();
row.setTrxDate(item.getTrxDate());
row.setSuspiciousPersonName(item.getSuspiciousPersonName());
row.setRelatedPersonName(item.getRelatedPersonName());
row.setLeAccountNo(item.getLeAccountNo());
row.setLeAccountName(item.getLeAccountName());
row.setCustomerAccountName(item.getCustomerAccountName());
row.setCustomerAccountNo(item.getCustomerAccountNo());
row.setRelatedStaffDisplay(formatRelatedStaff(item.getRelatedStaffName(), item.getRelatedStaffCode()));
row.setRelationType(item.getRelationType());
row.setSummaryAndCashType(formatSummaryAndCashType(item.getUserMemo(), item.getCashType()));
row.setUserMemo(item.getUserMemo());
row.setCashType(item.getCashType());
row.setHitTags(item.getHitTags());
row.setDisplayAmount(item.getDisplayAmount());
return row;
}
@@ -566,21 +736,23 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row;
}
private List<CcdiProjectOverviewReportParamVO> buildReportParams(Long projectId) {
ModelParamAllVO response = modelParamService.selectAllParams(projectId);
return defaultList(response == null ? null : response.getModels()).stream()
.flatMap(model -> defaultList(model.getParams()).stream().map(param -> {
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName(model.getModelName());
row.setParamName(param.getParamName());
row.setParamValue(param.getParamValue());
row.setParamUnit(param.getParamUnit());
row.setParamDesc(param.getParamDesc());
return row;
}))
private List<CcdiProjectOverviewReportParamVO> buildReportParams(CcdiProject project) {
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : project.getProjectId();
return defaultList(modelParamMapper.selectByProjectId(effectiveProjectId)).stream()
.map(this::buildReportParamRow)
.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 "-";
@@ -607,6 +779,21 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return safeMemo + "/" + safeCashType;
}
private String joinModelNames(List<String> modelNames) {
return defaultList(modelNames).stream()
.filter(item -> item != null && !item.isBlank())
.distinct()
.collect(Collectors.joining(""));
}
private String joinHitTagNames(List<CcdiProjectRiskHitTagVO> hitTags) {
return defaultList(hitTags).stream()
.map(CcdiProjectRiskHitTagVO::getRuleName)
.filter(item -> item != null && !item.isBlank())
.distinct()
.collect(Collectors.joining(""));
}
private CcdiProjectPersonAnalysisAbnormalDetailVO buildAbnormalDetail(
List<CcdiBankStatementListVO> statementRows,
List<CcdiProjectPersonAnalysisObjectRecordVO> objectRows

View File

@@ -44,19 +44,33 @@ public class CcdiProjectRiskDetailWorkbookExporter {
private void writeSuspiciousSheet(Sheet sheet, List<CcdiProjectSuspiciousTransactionExcel> rows) {
Row header = sheet.createRow(0);
String[] headers = { "交易时间", "可疑人员", "关联人", "关联员工", "关系", "摘要/交易类型", "交易金额" };
String[] headers = {
"交易时间",
"本方账户",
"本方主体",
"对方名称",
"对方账户",
"关联员工",
"摘要",
"交易类型",
"异常标签",
"交易金额"
};
writeHeader(header, headers);
for (int i = 0; i < rows.size(); i++) {
CcdiProjectSuspiciousTransactionExcel item = rows.get(i);
Row row = sheet.createRow(i + 1);
row.createCell(0).setCellValue(safeText(item.getTrxDate()));
row.createCell(1).setCellValue(safeText(item.getSuspiciousPersonName()));
row.createCell(2).setCellValue(safeText(item.getRelatedPersonName()));
row.createCell(3).setCellValue(safeText(item.getRelatedStaffDisplay()));
row.createCell(4).setCellValue(safeText(item.getRelationType()));
row.createCell(5).setCellValue(safeText(item.getSummaryAndCashType()));
row.createCell(6).setCellValue(safeNumber(item.getDisplayAmount()));
row.createCell(1).setCellValue(safeText(item.getLeAccountNo()));
row.createCell(2).setCellValue(safeText(item.getLeAccountName()));
row.createCell(3).setCellValue(safeText(item.getCustomerAccountName()));
row.createCell(4).setCellValue(safeText(item.getCustomerAccountNo()));
row.createCell(5).setCellValue(safeText(item.getRelatedStaffDisplay()));
row.createCell(6).setCellValue(safeText(item.getUserMemo()));
row.createCell(7).setCellValue(safeText(item.getCashType()));
row.createCell(8).setCellValue(safeText(item.getHitTags()));
row.createCell(9).setCellValue(safeNumber(item.getDisplayAmount()));
}
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.ProjectAccessScope;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
@@ -14,6 +15,7 @@ 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.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
@@ -54,6 +56,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Resource
private ApplicationEventPublisher applicationEventPublisher;
@Resource
private CcdiProjectAccessService projectAccessService;
@Override
@Transactional(rollbackFor = Exception.class)
public CcdiProjectVO createProject(CcdiProjectSaveDTO dto) {
@@ -82,7 +87,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
// 5. 返回VO
CcdiProjectVO vo = new CcdiProjectVO();
BeanUtils.copyProperties(project, vo);
fillLatestTagFailure(project, vo);
fillProjectExtraFields(project, vo);
return vo;
}
@@ -96,6 +101,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
if (existingProject == null) {
throw new ServiceException("项目不存在");
}
projectAccessService.assertCanOperate(dto.getProjectId());
// 只更新允许修改的字段
existingProject.setProjectName(dto.getProjectName());
@@ -106,39 +112,59 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
CcdiProjectVO vo = new CcdiProjectVO();
BeanUtils.copyProperties(existingProject, vo);
fillProjectExtraFields(existingProject, vo);
return vo;
}
@Override
public boolean deleteProject(Long projectId) {
return projectMapper.deleteById(projectId) > 0;
public boolean deleteProject(Long projectId, String operator) {
projectAccessService.assertCanDelete(projectId);
return projectMapper.markProjectDeleted(projectId, resolveOperator(operator)) > 0;
}
@Override
public void restoreProject(Long projectId, String operator) {
projectAccessService.assertCanManageDeletedProjects();
int rows = projectMapper.restoreDeletedProject(projectId, resolveOperator(operator));
if (rows <= 0) {
throw new ServiceException("仅已删除项目允许恢复");
}
log.info("【项目】项目状态变更: projectId={}, oldStatus={}, oldStatusLabel={}, newStatus={}, newStatusLabel={}, operator={}",
projectId, CcdiProjectStatusConstants.DELETED, resolveStatusLabel(CcdiProjectStatusConstants.DELETED),
CcdiProjectStatusConstants.COMPLETED, resolveStatusLabel(CcdiProjectStatusConstants.COMPLETED),
resolveOperator(operator));
}
@Override
public CcdiProjectVO getProjectById(Long projectId) {
projectAccessService.assertCanRead(projectId);
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
return null;
}
CcdiProjectVO vo = new CcdiProjectVO();
BeanUtils.copyProperties(project, vo);
fillLatestTagFailure(project, vo);
fillProjectExtraFields(project, vo);
return vo;
}
@Override
public Page<CcdiProjectVO> selectProjectPage(Page<CcdiProjectVO> page, CcdiProjectQueryDTO queryDTO) {
return projectMapper.selectProjectPage(page, queryDTO);
ProjectAccessScope scope = projectAccessService.buildCurrentScope();
Page<CcdiProjectVO> result = projectMapper.selectProjectPage(page, queryDTO, scope);
fillProjectExtraFields(result.getRecords());
return result;
}
@Override
public List<CcdiProjectHistoryListItemVO> listHistoryProjects(CcdiProjectQueryDTO queryDTO) {
return projectMapper.selectHistoryProjects(queryDTO);
return projectMapper.selectHistoryProjects(queryDTO, projectAccessService.buildCurrentScope());
}
@Override
@Transactional(rollbackFor = Exception.class)
public CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, String operator) {
projectAccessService.assertSourceProjectsReadable(dto.getSourceProjectIds());
CcdiProjectSaveDTO saveDTO = new CcdiProjectSaveDTO();
saveDTO.setProjectName(dto.getProjectName());
saveDTO.setDescription(dto.getDescription());
@@ -158,50 +184,49 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public CcdiProjectStatusCountsVO getStatusCounts() {
CcdiProjectStatusCountsVO vo = new CcdiProjectStatusCountsVO();
ProjectAccessScope scope = projectAccessService.buildCurrentScope();
// 统计全部项目
Long totalCount = projectMapper.selectCount(null);
LambdaQueryWrapper<CcdiProject> baseWrapper = buildScopeWrapper(scope);
Long totalCount = projectMapper.selectCount(baseWrapper);
vo.setAll(totalCount);
// 统计进行中项目状态0
Long status0Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.PROCESSING)
);
Long status0Count = projectMapper.selectCount(buildScopeWrapper(scope)
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.PROCESSING));
vo.setStatus0(status0Count);
// 统计已完成项目状态1
Long status1Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.COMPLETED)
);
Long status1Count = projectMapper.selectCount(buildScopeWrapper(scope)
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.COMPLETED));
vo.setStatus1(status1Count);
// 统计已归档项目状态2
Long status2Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.ARCHIVED)
);
Long status2Count = projectMapper.selectCount(buildScopeWrapper(scope)
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.ARCHIVED));
vo.setStatus2(status2Count);
Long status3Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAGGING)
);
Long status3Count = projectMapper.selectCount(buildScopeWrapper(scope)
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAGGING));
vo.setStatus3(status3Count);
Long status4Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAG_FAILED)
);
Long status4Count = projectMapper.selectCount(buildScopeWrapper(scope)
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAG_FAILED));
vo.setStatus4(status4Count);
Long status5Count = 0L;
if (isProjectAdmin(scope)) {
status5Count = projectMapper.selectDeletedProjectCount(scope);
}
vo.setStatus5(status5Count);
return vo;
}
@Override
public void archiveProject(Long projectId, String operator) {
CcdiProject project = getRequiredProject(projectId);
projectAccessService.assertCanOperate(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException("项目已归档,无需重复操作");
}
@@ -223,6 +248,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
public void updateProjectStatus(Long projectId, String status, String operator) {
CcdiProject project = getRequiredProject(projectId);
String oldStatus = project.getStatus();
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目不允许更新状态");
}
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())
&& !CcdiProjectStatusConstants.ARCHIVED.equals(status)) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
@@ -241,6 +269,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public void ensureProjectCanStartTagging(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目不允许重新进入打标流程");
}
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
@@ -249,6 +280,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public void ensureProjectNotArchived(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目暂不允许操作");
}
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException(message);
}
@@ -257,6 +291,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public void ensureProjectWritable(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目暂不允许操作");
}
if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) {
throw new ServiceException(message);
}
@@ -277,6 +314,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
case CcdiProjectStatusConstants.ARCHIVED -> "已归档";
case CcdiProjectStatusConstants.TAGGING -> "打标中";
case CcdiProjectStatusConstants.TAG_FAILED -> "打标失败";
case CcdiProjectStatusConstants.DELETED -> "已删除";
default -> "未知";
};
}
@@ -293,6 +331,44 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
vo.setLatestTagTaskEndTime(latestFailedTask.getEndTime());
}
private LambdaQueryWrapper<CcdiProject> buildScopeWrapper(ProjectAccessScope scope) {
LambdaQueryWrapper<CcdiProject> wrapper = new LambdaQueryWrapper<>();
wrapper.ne(CcdiProject::getStatus, CcdiProjectStatusConstants.DELETED);
if (scope != null && !scope.isViewAllProjects()) {
wrapper.eq(CcdiProject::getCreateBy, scope.getUsername());
}
return wrapper;
}
private boolean isProjectAdmin(ProjectAccessScope scope) {
return scope != null && (scope.isSuperAdmin() || scope.isProjectManager());
}
private void fillProjectExtraFields(List<CcdiProjectVO> records) {
if (records == null || records.isEmpty()) {
return;
}
ProjectAccessScope scope = projectAccessService.buildCurrentScope();
for (CcdiProjectVO vo : records) {
fillProjectAccessFields(vo, scope);
}
}
private void fillProjectExtraFields(CcdiProject project, CcdiProjectVO vo) {
fillLatestTagFailure(project, vo);
fillProjectAccessFields(vo, projectAccessService.buildCurrentScope());
}
private void fillProjectAccessFields(CcdiProjectVO vo, ProjectAccessScope scope) {
if (vo == null || scope == null) {
return;
}
boolean ownedByCurrentUser = Objects.equals(scope.getUsername(), vo.getCreateBy());
vo.setOwnedByCurrentUser(ownedByCurrentUser);
vo.setCanOperate(scope.isSuperAdmin() || ownedByCurrentUser);
vo.setCanDelete(isProjectAdmin(scope) || ownedByCurrentUser);
}
private String resolveOperator(String operator) {
return StringUtils.hasText(operator) ? operator : "system";
}

View File

@@ -215,6 +215,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{item}
</foreach>
</if>
<if test="query.ourCertNos != null and query.ourCertNos.size() > 0">
AND bs.cret_no IN
<foreach collection="query.ourCertNos" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</if>
<if test="query.ourBanks != null and query.ourBanks.size() > 0">
AND bs.BANK IN
<foreach collection="query.ourBanks" item="item" open="(" separator="," close=")">

View File

@@ -41,6 +41,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and trim(bs.cret_no) != ''
</sql>
<sql id="externalPersonPredicateSql">
bs.cret_no is not null
and trim(bs.cret_no) != ''
and not exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
and not exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.status = 1
and relation.relation_cert_no = bs.cret_no
)
and trim(IFNULL(bs.LE_ACCOUNT_NAME, '')) &lt;&gt; trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))
</sql>
<sql id="cashDepositPredicate">
(
(
@@ -466,6 +483,74 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
)
</select>
<select id="selectExternalSingleLargeAmountStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
'”单笔交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and <include refid="financialProductExclusionPredicate"/>
</select>
<select id="selectExternalCumulativeTransactionAmountObjects" resultMap="BankTagObjectHitResultMap">
select
'EXTERNAL_CERT_NO' AS objectType,
t.certNo AS objectKey,
CONCAT(
'外部人员“', IFNULL(t.personName, ''),
'”累计交易金额 ', CAST(t.totalAmount AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
) AS reasonDetail
from (
select
bs.cret_no AS certNo,
max(IFNULL(bs.LE_ACCOUNT_NAME, '')) AS personName,
ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) AS totalAmount
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and <include refid="financialProductExclusionPredicate"/>
group by bs.cret_no
having ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) > #{threshold}
) t
</select>
<select id="selectExternalAnnualTurnoverObjects" resultMap="BankTagObjectHitResultMap">
select
'EXTERNAL_CERT_NO' AS objectType,
t.certNo AS objectKey,
CONCAT(
'外部人员“', IFNULL(t.personName, ''),
'”近一年流水交易额 ', CAST(t.annualAmount AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
) AS reasonDetail
from (
select
bs.cret_no AS certNo,
max(IFNULL(bs.LE_ACCOUNT_NAME, '')) AS personName,
ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) AS annualAmount
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
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 bs.cret_no
having ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) > #{threshold}
) t
</select>
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
hit.bankStatementId AS bankStatementId,
@@ -702,6 +787,72 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
)
</select>
<select id="selectExternalGamblingMemoStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
'”摘要/对手方命中疑似赌博关键词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CASH_TYPE, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
)
</select>
<select id="selectExternalMultiPartyGamblingTransferObjects" resultMap="BankTagObjectHitResultMap">
select
'EXTERNAL_CERT_NO' AS objectType,
t.certNo AS objectKey,
CONCAT(
'外部人员“', IFNULL(MAX(t.personName), ''),
'”交易日 ', MAX(t.tradeDate),
' 发生 ', CAST(MAX(t.hitCount) AS CHAR),
' 笔疑似赌博交易,涉及 ', CAST(MAX(t.partyCount) AS CHAR),
' 个对手方,金额合计 ', CAST(MAX(t.totalAmount) AS CHAR), ' 元'
) AS reasonDetail
from (
select
source.certNo AS certNo,
max(source.personName) AS personName,
source.tradeDate AS tradeDate,
COUNT(1) AS hitCount,
COUNT(DISTINCT source.customerAccountName) AS partyCount,
ROUND(SUM(source.tradeAmount), 2) AS totalAmount
from (
select
bs.cret_no AS certNo,
IFNULL(bs.LE_ACCOUNT_NAME, '') AS personName,
LEFT(TRIM(bs.TRX_DATE), 10) AS tradeDate,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS tradeAmount
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) between #{amountMinThreshold} and #{amountMaxThreshold}
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; ''
and (
IFNULL(bs.USER_MEMO, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包|牌局|赌'
or IFNULL(bs.CASH_TYPE, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包|牌局|赌'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay'
)
) source
group by source.certNo, source.tradeDate
having COUNT(1) > 2
and COUNT(DISTINCT source.customerAccountName) >= 2
) t
group by t.certNo
</select>
<select id="selectSpecialAmountTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
@@ -728,6 +879,74 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
)
</select>
<select id="selectExternalToStaffOrFamilyTransactionStatements" resultMap="BankTagStatementHitResultMap">
select distinct
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
'”与', CASE
WHEN counter_account.owner_type = 'EMPLOYEE' THEN '员工'
WHEN counter_account.owner_type = 'RELATION' THEN '员工亲属'
WHEN counter_staff.id_card is not null THEN '员工'
WHEN counter_relation.relation_cert_no is not null THEN '员工亲属'
ELSE '员工/员工亲属'
END,
'“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”发生资金往来,交易金额 ',
CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
left join ccdi_account_info counter_account
on trim(bs.CUSTOMER_ACCOUNT_NO) != ''
and counter_account.account_no = trim(bs.CUSTOMER_ACCOUNT_NO)
and counter_account.owner_type in ('EMPLOYEE', 'RELATION', 'INTERMEDIARY', 'CREDIT_CUSTOMER')
left join ccdi_base_staff counter_staff
on counter_account.account_no is null
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and counter_staff.name = trim(bs.CUSTOMER_ACCOUNT_NAME)
left join ccdi_staff_fmy_relation counter_relation
on counter_account.account_no is null
and counter_relation.status = 1
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and counter_relation.relation_name = trim(bs.CUSTOMER_ACCOUNT_NAME)
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and (
counter_account.owner_type in ('EMPLOYEE', 'RELATION')
or (
counter_account.account_no is null
and (
counter_staff.id_card is not null
or counter_relation.relation_cert_no is not null
)
)
)
</select>
<select id="selectExternalNightTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
'”夜间交易,交易时间 ', IFNULL(bs.TRX_DATE, ''),
',对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and (
HOUR(STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s')) >= 22
or HOUR(STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s')) &lt; 6
)
</select>
<select id="selectMonthlyFixedIncomeObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,

View File

@@ -40,10 +40,23 @@
FROM ccdi_project p
LEFT JOIN sys_user u ON p.create_by = u.user_name AND u.del_flag = '0'
<where>
<choose>
<when test="queryDTO != null and queryDTO.includeDeleted == true and scope != null and scope.viewAllProjects">
AND p.del_flag = '2'
AND p.status = '5'
</when>
<otherwise>
AND p.del_flag = '0'
AND p.status != '5'
</otherwise>
</choose>
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
<if test="queryDTO.projectName != null and queryDTO.projectName != ''">
AND p.project_name LIKE CONCAT('%', #{queryDTO.projectName}, '%')
</if>
<if test="queryDTO.status != null and queryDTO.status != ''">
<if test="!(queryDTO != null and queryDTO.includeDeleted == true) and queryDTO.status != null and queryDTO.status != ''">
AND p.status = #{queryDTO.status}
</if>
</where>
@@ -61,6 +74,10 @@
FROM ccdi_project p
<where>
p.status in ('1', '2')
AND p.del_flag = '0'
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
<if test="queryDTO.projectName != null and queryDTO.projectName != ''">
AND p.project_name LIKE CONCAT('%', #{queryDTO.projectName}, '%')
</if>
@@ -68,6 +85,41 @@
ORDER BY p.update_time DESC
</select>
<update id="markProjectDeleted">
update ccdi_project
set status = '5',
del_flag = '2',
update_by = #{operator},
update_time = now()
where project_id = #{projectId}
and del_flag = '0'
and status != '5'
</update>
<update id="restoreDeletedProject">
update ccdi_project
set status = '1',
del_flag = '0',
is_archived = 0,
update_by = #{operator},
update_time = now()
where project_id = #{projectId}
and del_flag = '2'
and status = '5'
</update>
<select id="selectDeletedProjectCount" resultType="java.lang.Long">
select count(1)
from ccdi_project p
<where>
p.del_flag = '2'
and p.status = '5'
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
</where>
</select>
<update id="updateRiskCountsByProjectId">
update ccdi_project
set target_count = #{targetCount},

View File

@@ -33,6 +33,38 @@
select="selectRiskHitTagsByScope"/>
</resultMap>
<resultMap id="ExternalRiskModelPeopleItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO">
<id property="idNo" column="cert_no"/>
<result property="staffName" column="person_name"/>
<result property="staffCode" column="subject_type"/>
<result property="department" column="related_object"/>
<collection property="modelNames"
column="{projectId=project_id,certNo=cert_no,selectedModelCodes=selected_model_codes}"
ofType="java.lang.String"
select="selectExternalRiskModelNamesByScope"/>
<collection property="hitTagList"
column="{projectId=project_id,certNo=cert_no,selectedModelCodes=selected_model_codes}"
ofType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO"
select="selectExternalRiskHitTagsByScope"/>
</resultMap>
<resultMap id="ExternalPersonWarningItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningItemVO">
<result property="name" column="person_name"/>
<result property="idNo" column="cert_no"/>
<result property="subjectType" column="subject_type"/>
<result property="riskLevel" column="risk_level_name"/>
<result property="riskLevelType" column="risk_level_type"/>
<result property="riskCount" column="risk_count"/>
<result property="modelCount" column="model_count"/>
<result property="riskPoint" column="risk_point"/>
<result property="relatedObject" column="related_object"/>
<result property="latestTradeTime" column="latest_trade_time"/>
<collection property="riskPointTagList"
column="{projectId=project_id,certNo=cert_no,selectedModelCodes=selected_model_codes}"
ofType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO"
select="selectExternalRiskHitTagsByScope"/>
</resultMap>
<resultMap id="SuspiciousTransactionItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/>
@@ -115,6 +147,13 @@
) tens
</sql>
<sql id="externalModelCodeFilterSql">
('EXTERNAL_LARGE_TRANSACTION',
'EXTERNAL_ABNORMAL_TRANSACTION',
'EXTERNAL_SUSPICIOUS_GAMBLING',
'EXTERNAL_SUSPICIOUS_RELATION')
</sql>
<sql id="resolvedEmployeeRiskBaseSql">
select distinct
tr.id,
@@ -494,6 +533,408 @@
order by result.staff_name asc, result.staff_id_card asc
</select>
<select id="selectRiskModelPeopleList" resultMap="RiskModelPeopleItemResultMap">
<bind name="projectId" value="query.projectId"/>
select
result.project_id,
result.staff_id_card,
result.staff_name,
result.staff_code,
result.dept_name as department,
#{query.modelCodesCsv} as selected_model_codes
from ccdi_project_overview_employee_result result
where 1 = 1
and result.project_id = #{query.projectId}
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
<choose>
<when test="query.matchMode == 'ALL'">
<foreach collection="query.modelCodes" item="modelCode">
and find_in_set(#{modelCode}, result.model_codes_csv)
</foreach>
</when>
<otherwise>
and (
<foreach collection="query.modelCodes" item="modelCode" separator=" or ">
find_in_set(#{modelCode}, result.model_codes_csv)
</foreach>
)
</otherwise>
</choose>
</if>
<if test="query.keyword != null and query.keyword != ''">
and (
result.staff_name like concat('%', trim(#{query.keyword}), '%')
or result.staff_code like concat('%', trim(#{query.keyword}), '%')
)
</if>
<if test="query.deptId != null">
and result.dept_id = #{query.deptId}
</if>
order by result.staff_name asc, result.staff_id_card asc
</select>
<sql id="externalPersonSubjectSql">
select
bs.project_id,
bs.cret_no as cert_no,
coalesce(
max(intermediary.name),
max(customer.name),
max(nullif(trim(bs.LE_ACCOUNT_NAME), '')),
'外部人员'
) as person_name,
case
when max(case when intermediary.person_id is not null then 1 else 0 end) > 0 then '中介'
when max(case when customer.person_id is not null then 1 else 0 end) > 0 then '客户'
else '外部人员'
end as subject_type
from ccdi_bank_statement bs
left join ccdi_base_staff staff
on staff.id_card = bs.cret_no
left join ccdi_staff_fmy_relation relation
on relation.status = 1
and relation.relation_cert_no = bs.cret_no
left join ccdi_biz_intermediary intermediary
on intermediary.person_sub_type = '本人'
and intermediary.person_id = bs.cret_no
left join ccdi_credit_customer_base customer
on customer.person_id = bs.cret_no
where bs.project_id = #{externalProjectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''
and staff.id_card is null
and relation.relation_cert_no is null
group by bs.project_id, bs.cret_no
</sql>
<sql id="externalPersonSourceSql">
select
subject.project_id,
subject.cert_no,
subject.person_name,
subject.subject_type,
bs.bank_statement_id,
bs.TRX_DATE as trx_date,
bs.CUSTOMER_ACCOUNT_NAME as customer_account_name,
bs.customer_cert_no,
case
when counter_account.owner_type = 'EMPLOYEE' then '员工'
when counter_account.owner_type = 'RELATION' then '员工亲属'
when counter_account.owner_type = 'CREDIT_CUSTOMER' then '信贷客户'
when counter_account.owner_type = 'INTERMEDIARY' then '中介库人员'
when counter_staff.id_card is not null then '员工'
when counter_relation.relation_cert_no is not null then '员工亲属'
when counter_intermediary.person_id is not null then '中介库人员'
else null
end as related_object,
tr.model_code,
tr.model_name,
tr.rule_code,
tr.rule_name,
tr.risk_level,
tr.reason_detail
from (
<include refid="externalPersonSubjectSql"/>
) subject
inner join ccdi_bank_statement bs
on bs.project_id = subject.project_id
and bs.cret_no = subject.cert_no
inner join ccdi_bank_statement_tag_result tr
on tr.project_id = bs.project_id
and tr.bank_statement_id = bs.bank_statement_id
and tr.model_code in <include refid="externalModelCodeFilterSql"/>
left join ccdi_account_info counter_account
on trim(bs.CUSTOMER_ACCOUNT_NO) != ''
and counter_account.account_no = trim(bs.CUSTOMER_ACCOUNT_NO)
and counter_account.owner_type in ('EMPLOYEE', 'RELATION', 'INTERMEDIARY', 'CREDIT_CUSTOMER')
left join ccdi_base_staff counter_staff
on counter_account.account_no is null
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and counter_staff.name = trim(bs.CUSTOMER_ACCOUNT_NAME)
left join ccdi_staff_fmy_relation counter_relation
on counter_account.account_no is null
and counter_relation.status = 1
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and counter_relation.relation_name = trim(bs.CUSTOMER_ACCOUNT_NAME)
left join ccdi_biz_intermediary counter_intermediary
on counter_account.account_no is null
and counter_intermediary.person_sub_type = '本人'
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and counter_intermediary.name = trim(bs.CUSTOMER_ACCOUNT_NAME)
where trim(ifnull(bs.LE_ACCOUNT_NAME, '')) != trim(ifnull(bs.CUSTOMER_ACCOUNT_NAME, ''))
union all
select
subject.project_id,
subject.cert_no,
subject.person_name,
subject.subject_type,
null as bank_statement_id,
null as trx_date,
null as customer_account_name,
null as customer_cert_no,
'资金' as related_object,
tr.model_code,
tr.model_name,
tr.rule_code,
tr.rule_name,
tr.risk_level,
tr.reason_detail
from (
<include refid="externalPersonSubjectSql"/>
) subject
inner join ccdi_bank_statement_tag_result tr
on tr.project_id = subject.project_id
and tr.object_type = 'EXTERNAL_CERT_NO'
and tr.object_key = subject.cert_no
and tr.model_code in <include refid="externalModelCodeFilterSql"/>
</sql>
<sql id="externalPersonAggregateSql">
select
source.project_id,
source.cert_no,
max(source.person_name) as person_name,
max(source.subject_type) as subject_type,
count(*) as risk_count,
count(distinct source.model_code) as model_count,
group_concat(distinct source.rule_name order by source.rule_name separator '、') as risk_point,
group_concat(distinct source.related_object order by source.related_object separator '、') as related_object,
max(source.trx_date) as latest_trade_time,
case
when sum(case when source.risk_level = 'HIGH' then 1 else 0 end) > 0 then 'HIGH'
when sum(case when source.risk_level = 'MEDIUM' then 1 else 0 end) > 0 then 'MEDIUM'
else 'LOW'
end as risk_level_code,
null as selected_model_codes
from (
<include refid="externalPersonSourceSql"/>
) source
group by source.project_id, source.cert_no
</sql>
<sql id="externalPersonWarningSelectSql">
select
agg.project_id,
agg.cert_no,
agg.person_name,
agg.subject_type,
agg.risk_count,
agg.model_count,
agg.risk_point,
coalesce(agg.related_object, '-') as related_object,
agg.latest_trade_time,
agg.risk_level_code,
case
when agg.risk_level_code = 'HIGH' then '高风险'
when agg.risk_level_code = 'MEDIUM' then '中风险'
else '低风险'
end as risk_level_name,
case
when agg.risk_level_code = 'HIGH' then 'danger'
when agg.risk_level_code = 'MEDIUM' then 'warning'
else 'info'
end as risk_level_type,
case
when agg.risk_level_code = 'HIGH' then 1
when agg.risk_level_code = 'MEDIUM' then 2
else 3
end as risk_level_sort,
agg.selected_model_codes
from (
<include refid="externalPersonAggregateSql"/>
) agg
</sql>
<select id="selectExternalPersonWarningPage" resultMap="ExternalPersonWarningItemResultMap">
<bind name="externalProjectId" value="query.projectId"/>
select *
from (
<include refid="externalPersonWarningSelectSql"/>
) warning
order by warning.risk_level_sort asc, warning.model_count desc, warning.risk_count desc, warning.latest_trade_time desc
</select>
<select id="selectExternalPersonWarningList" resultMap="ExternalPersonWarningItemResultMap">
<bind name="externalProjectId" value="projectId"/>
select *
from (
<include refid="externalPersonWarningSelectSql"/>
) warning
order by warning.risk_level_sort asc, warning.model_count desc, warning.risk_count desc, warning.latest_trade_time desc
</select>
<select id="selectExternalRiskSummaryByProjectId" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO">
<bind name="externalProjectId" value="projectId"/>
select
count(*) as total,
coalesce(sum(case when risk.risk_level_code = 'HIGH' then 1 else 0 end), 0) as high,
coalesce(sum(case when risk.risk_level_code = 'MEDIUM' then 1 else 0 end), 0) as medium,
coalesce(sum(case when risk.risk_level_code = 'LOW' then 1 else 0 end), 0) as low,
coalesce(sum(case when risk.risk_level_code is null then 1 else 0 end), 0) as noRisk
from (
<include refid="externalPersonSubjectSql"/>
) subject
left join (
<include refid="externalPersonAggregateSql"/>
) risk
on risk.project_id = subject.project_id
and risk.cert_no = subject.cert_no
</select>
<select id="selectExternalRiskModelCardsByProjectId" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO">
<bind name="externalProjectId" value="projectId"/>
select
model_scope.model_code,
max(model_scope.model_name) as model_name,
count(*) as warning_count,
count(distinct model_scope.cert_no) as people_count
from (
<include refid="externalPersonSourceSql"/>
) model_scope
group by model_scope.model_code
order by warning_count desc, model_scope.model_code asc
</select>
<select id="selectExternalRiskModelPeoplePage" resultMap="ExternalRiskModelPeopleItemResultMap">
<bind name="externalProjectId" value="query.projectId"/>
select
warning.project_id,
warning.cert_no,
warning.person_name,
warning.subject_type,
warning.related_object,
#{query.modelCodesCsv} as selected_model_codes
from (
<include refid="externalPersonWarningSelectSql"/>
) warning
where 1 = 1
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
<choose>
<when test="query.matchMode == 'ALL'">
<foreach collection="query.modelCodes" item="modelCode">
and exists (
select 1
from (
<include refid="externalPersonSourceSql"/>
) source
where source.cert_no = warning.cert_no
and source.model_code = #{modelCode}
)
</foreach>
</when>
<otherwise>
and exists (
select 1
from (
<include refid="externalPersonSourceSql"/>
) source
where source.cert_no = warning.cert_no
and source.model_code in
<foreach collection="query.modelCodes" item="modelCode" open="(" separator="," close=")">
#{modelCode}
</foreach>
)
</otherwise>
</choose>
</if>
<if test="query.keyword != null and query.keyword != ''">
and (
warning.person_name like concat('%', trim(#{query.keyword}), '%')
or warning.cert_no like concat('%', trim(#{query.keyword}), '%')
)
</if>
order by warning.person_name asc, warning.cert_no asc
</select>
<select id="selectExternalRiskModelPeopleList" resultMap="ExternalRiskModelPeopleItemResultMap">
<bind name="externalProjectId" value="query.projectId"/>
select
warning.project_id,
warning.cert_no,
warning.person_name,
warning.subject_type,
warning.related_object,
#{query.modelCodesCsv} as selected_model_codes
from (
<include refid="externalPersonWarningSelectSql"/>
) warning
where 1 = 1
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
<choose>
<when test="query.matchMode == 'ALL'">
<foreach collection="query.modelCodes" item="modelCode">
and exists (
select 1
from (
<include refid="externalPersonSourceSql"/>
) source
where source.cert_no = warning.cert_no
and source.model_code = #{modelCode}
)
</foreach>
</when>
<otherwise>
and exists (
select 1
from (
<include refid="externalPersonSourceSql"/>
) source
where source.cert_no = warning.cert_no
and source.model_code in
<foreach collection="query.modelCodes" item="modelCode" open="(" separator="," close=")">
#{modelCode}
</foreach>
)
</otherwise>
</choose>
</if>
<if test="query.keyword != null and query.keyword != ''">
and (
warning.person_name like concat('%', trim(#{query.keyword}), '%')
or warning.cert_no like concat('%', trim(#{query.keyword}), '%')
)
</if>
order by warning.person_name asc, warning.cert_no asc
</select>
<select id="selectExternalRiskModelNamesByScope" resultType="java.lang.String">
<bind name="externalProjectId" value="projectId"/>
select distinct source.model_name
from (
<include refid="externalPersonSourceSql"/>
) source
where source.cert_no = #{certNo}
<if test="selectedModelCodes != null and selectedModelCodes != ''">
and find_in_set(source.model_code, #{selectedModelCodes})
</if>
order by source.model_name asc
</select>
<select id="selectExternalRiskHitTagsByScope" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO">
<bind name="externalProjectId" value="projectId"/>
select
source.model_code as modelCode,
max(source.model_name) as modelName,
source.rule_code as ruleCode,
max(source.rule_name) as ruleName,
max(source.risk_level) as riskLevel,
coalesce(
max(case when source.bank_statement_id is null then nullif(source.reason_detail, '') end),
max(nullif(source.reason_detail, ''))
) as reasonDetail
from (
<include refid="externalPersonSourceSql"/>
) source
where source.cert_no = #{certNo}
<if test="selectedModelCodes != null and selectedModelCodes != ''">
and find_in_set(source.model_code, #{selectedModelCodes})
</if>
group by source.model_code, source.rule_code
order by source.model_code asc, source.rule_code asc
</select>
<sql id="suspiciousTransactionBaseSql">
select
bs.bank_statement_id as bankStatementId,
@@ -606,6 +1047,34 @@
) hits
</sql>
<sql id="externalSuspiciousTransactionSql">
select
bs.bank_statement_id as bankStatementId,
bs.TRX_DATE as trxDate,
source.person_name as relatedPersonName,
null as relatedStaffName,
null as relatedStaffCode,
source.subject_type as relationType,
bs.USER_MEMO as userMemo,
bs.CASH_TYPE as cashType,
case
when ifnull(bs.AMOUNT_CR, 0) > 0 then bs.AMOUNT_CR
when ifnull(bs.AMOUNT_DR, 0) > 0 then -bs.AMOUNT_DR
else 0
end as displayAmount,
1 as hasModelRuleHit,
0 as hasNameListHit,
source.person_name as suspiciousPersonName,
9 as matchPriority,
'外部人员预警' as nameListHitType
from (
<bind name="externalProjectId" value="query.projectId"/>
<include refid="externalPersonSourceSql"/>
) source
inner join ccdi_bank_statement bs
on bs.bank_statement_id = source.bank_statement_id
</sql>
<sql id="suspiciousTransactionMergedSql">
select
base.bankStatementId,
@@ -652,6 +1121,27 @@
inner join (
<include refid="suspiciousTransactionNameHitSql"/>
) name_hits on name_hits.bankStatementId = base.bankStatementId
union all
select
external_hits.bankStatementId,
external_hits.trxDate,
external_hits.relatedPersonName,
external_hits.relatedStaffName,
external_hits.relatedStaffCode,
external_hits.relationType,
external_hits.userMemo,
external_hits.cashType,
external_hits.displayAmount,
external_hits.hasModelRuleHit,
external_hits.hasNameListHit,
external_hits.suspiciousPersonName,
external_hits.matchPriority,
external_hits.nameListHitType
from (
<include refid="externalSuspiciousTransactionSql"/>
) external_hits
</sql>
<sql id="suspiciousTransactionAggregatedSql">
@@ -706,6 +1196,9 @@
<when test="query.suspiciousType == 'MODEL_RULE'">
where final_result.hasModelRuleHit = 1
</when>
<when test="query.suspiciousType == 'EXTERNAL_PERSON'">
where final_result.nameListHitType = '外部人员预警'
</when>
<otherwise>
where final_result.hasModelRuleHit = 1 or final_result.hasNameListHit = 1
</otherwise>
@@ -951,7 +1444,8 @@
max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].modelName')))) as model_name,
json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].ruleCode'))) as rule_code,
max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].ruleName')))) as rule_name,
max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].riskLevel')))) as risk_level
max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].riskLevel')))) as risk_level,
max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].reasonDetail')))) as reason_detail
from ccdi_project_overview_employee_result result
join (
<include refid="jsonArrayIndexSql"/>
@@ -1073,4 +1567,5 @@
group by base.staff_id_card
) agg
</select>
</mapper>

View File

@@ -114,32 +114,18 @@
<sql id="projectEmployeeScopeSql">
select distinct
coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) as staff_id_card,
cast(coalesce(direct_staff.staff_id, statement_staff.staff_id, family_staff.staff_id) as char) as staff_code,
coalesce(direct_staff.name, statement_staff.name, family_staff.name) as staff_name,
statement_staff.id_card as staff_id_card,
cast(statement_staff.staff_id as char) as staff_code,
statement_staff.name as staff_name,
dept.dept_name
from ccdi_bank_statement_tag_result tr
left join ccdi_base_staff direct_staff
on tr.object_type = 'STAFF_ID_CARD'
and tr.object_key = direct_staff.id_card
left join ccdi_bank_statement bs
on tr.bank_statement_id = bs.bank_statement_id
left join ccdi_base_staff statement_staff
on (tr.object_key is null or tr.object_key = '')
and bs.cret_no = statement_staff.id_card
left join ccdi_staff_fmy_relation relation
on relation.status = 1
and (
((tr.object_key is null or tr.object_key = '') and bs.cret_no = relation.relation_cert_no)
or ((tr.object_key is not null and tr.object_key != '') and tr.object_type != 'STAFF_ID_CARD'
and tr.object_key = relation.relation_cert_no)
)
left join ccdi_base_staff family_staff
on relation.person_id = family_staff.id_card
from ccdi_bank_statement bs
inner join ccdi_base_staff statement_staff
on statement_staff.id_card = trim(bs.cret_no)
left join sys_dept dept
on dept.dept_id = coalesce(direct_staff.dept_id, statement_staff.dept_id, family_staff.dept_id)
where tr.project_id = #{projectId}
and coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) is not null
on dept.dept_id = statement_staff.dept_id
where bs.project_id = #{projectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''
</sql>
<sql id="spouseRelationSql">

View File

@@ -1,28 +1,20 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.lang.reflect.Method;
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.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class CcdiProjectControllerTest {
@@ -35,16 +27,6 @@ class CcdiProjectControllerTest {
@Test
void shouldArchiveProject() throws Exception {
try (MockedStatic<com.ruoyi.common.utils.SecurityUtils> mocked = mockStatic(com.ruoyi.common.utils.SecurityUtils.class)) {
mocked.when(com.ruoyi.common.utils.SecurityUtils::getUsername).thenReturn("tester");
AjaxResult result = controller.archiveProject(40L);
assertEquals(200, result.get("code"));
assertEquals("项目归档成功", result.get("msg"));
verify(projectService).archiveProject(40L, "tester");
}
Method method = CcdiProjectController.class.getMethod("archiveProject", Long.class);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
@@ -59,24 +41,32 @@ class CcdiProjectControllerTest {
}
@Test
void shouldImportFromHistoryAndReturnCreatedProject() {
CcdiProjectImportHistoryDTO dto = new CcdiProjectImportHistoryDTO();
dto.setProjectName("新建项目");
void shouldRestoreProject() throws Exception {
Method method = CcdiProjectController.class.getMethod("restoreProject", Long.class);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
Operation operation = method.getAnnotation(Operation.class);
CcdiProjectVO project = new CcdiProjectVO();
project.setProjectId(88L);
project.setProjectName("新建项目");
when(projectService.importFromHistory(eq(dto), eq("tester"))).thenReturn(project);
assertNotNull(postMapping);
assertEquals("/{projectId}/restore", postMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:edit')", preAuthorize.value());
assertNotNull(operation);
assertEquals("恢复项目", operation.summary());
}
try (MockedStatic<com.ruoyi.common.utils.SecurityUtils> mocked = mockStatic(com.ruoyi.common.utils.SecurityUtils.class)) {
mocked.when(com.ruoyi.common.utils.SecurityUtils::getUsername).thenReturn("tester");
@Test
void shouldAllowProjectListUsersToReachBusinessDeleteCheck() throws Exception {
Method method = CcdiProjectController.class.getMethod("deleteProject", Long.class);
DeleteMapping deleteMapping = method.getAnnotation(DeleteMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
Operation operation = method.getAnnotation(Operation.class);
AjaxResult result = controller.importFromHistory(dto);
assertEquals(200, result.get("code"));
assertEquals("项目创建成功", result.get("msg"));
assertSame(project, result.get("data"));
verify(projectService).importFromHistory(dto, "tester");
}
assertNotNull(deleteMapping);
assertEquals("/{projectId}", deleteMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:list')", preAuthorize.value());
assertNotNull(operation);
assertEquals("删除项目", operation.summary());
}
}

View File

@@ -281,7 +281,8 @@ class CcdiProjectOverviewControllerTest {
MockHttpServletResponse response = new MockHttpServletResponse();
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
CcdiProjectSuspiciousTransactionExcel row = new CcdiProjectSuspiciousTransactionExcel();
row.setSuspiciousPersonName("张三");
row.setLeAccountName("张三");
row.setCustomerAccountName("测试对手方");
row.setDisplayAmount(new java.math.BigDecimal("10.00"));
when(overviewService.exportSuspiciousTransactions(same(queryDTO))).thenReturn(List.of(row));

View File

@@ -104,4 +104,26 @@ class CcdiBankStatementTest {
assertEquals("330101199001011234", entity.getCustomerCertNo());
assertEquals("91330100123456789X", entity.getCustomerSocialCreditCode());
}
@Test
void testFromResponse_ShouldNormalizeCretNoBeforeDash() {
BankStatementItem item = new BankStatementItem();
item.setCretNo(" 330100198801010033 - 测试人员 ");
CcdiBankStatement entity = CcdiBankStatement.fromResponse(item);
assertNotNull(entity);
assertEquals("330100198801010033", entity.getCretNo());
}
@Test
void testFromResponse_ShouldNormalizeCretNoWithChineseDash() {
BankStatementItem item = new BankStatementItem();
item.setCretNo("330100198801010033测试人员");
CcdiBankStatement entity = CcdiBankStatement.fromResponse(item);
assertNotNull(entity);
assertEquals("330100198801010033", entity.getCretNo());
}
}

View File

@@ -13,6 +13,7 @@ import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagAnalysisMapperXmlTest {
@@ -26,11 +27,18 @@ class CcdiBankTagAnalysisMapperXmlTest {
"selectForexSellAmtStatements",
"selectLargePurchaseTransactionStatements",
"selectStockTfrLargeStatements",
"selectLargeStockTradingStatements"
"selectLargeStockTradingStatements",
"selectExternalSingleLargeAmountStatements",
"selectExternalNightTransactionStatements",
"selectExternalGamblingMemoStatements",
"selectExternalToStaffOrFamilyTransactionStatements"
);
private static final List<String> PHASE_TWO_OBJECT_SELECT_IDS = List.of(
"selectLowIncomeRelativeLargeTransactionObjects",
"selectMultiPartyGamblingTransferObjects",
"selectExternalCumulativeTransactionAmountObjects",
"selectExternalAnnualTurnoverObjects",
"selectExternalMultiPartyGamblingTransferObjects",
"selectMonthlyFixedIncomeObjects",
"selectFixedCounterpartyTransferObjects",
"selectSupplierConcentrationObjects",
@@ -100,7 +108,10 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(5, countMatches(xml, "where 1 = 0"));
assertEquals(
countMatches(xml, "占位SQL待补充真实规则"),
countMatches(xml, "where 1 = 0")
);
}
@Test
@@ -156,7 +167,11 @@ class CcdiBankTagAnalysisMapperXmlTest {
String xml = readXml(RESOURCE);
for (String selectId : PHASE_TWO_OBJECT_SELECT_IDS) {
String selectSql = extractSelectSql(xml, selectId);
assertTrue(selectSql.contains("'STAFF_ID_CARD' AS objectType"), () -> selectId + " 缺少 objectType");
assertTrue(
selectSql.contains("'STAFF_ID_CARD' AS objectType")
|| selectSql.contains("'EXTERNAL_CERT_NO' AS objectType"),
() -> selectId + " 缺少 objectType"
);
assertTrue(selectSql.contains("AS objectKey"), () -> selectId + " 缺少 objectKey");
assertTrue(selectSql.contains("reasonDetail"), () -> selectId + " 缺少 reasonDetail");
assertTrue(!selectSql.contains("where 1 = 0"), () -> selectId + " 仍是占位 SQL");
@@ -221,6 +236,31 @@ class CcdiBankTagAnalysisMapperXmlTest {
factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
}
@Test
void externalPersonRules_shouldUseExternalSubjectScopeAndCounterpartyEmployeeMatching() throws Exception {
String xml = readXml(RESOURCE);
String scopeSql = extractSqlFragment(xml, "externalPersonPredicateSql");
String relationSql = extractSelectSql(xml, "selectExternalToStaffOrFamilyTransactionStatements");
String annualSql = extractSelectSql(xml, "selectExternalAnnualTurnoverObjects");
String gamblingSql = extractSelectSql(xml, "selectExternalMultiPartyGamblingTransferObjects");
assertTrue(scopeSql.contains("bs.cret_no is not null"));
assertTrue(scopeSql.contains("staff.id_card = bs.cret_no"));
assertTrue(scopeSql.contains("relation.relation_cert_no = bs.cret_no"));
assertTrue(scopeSql.contains("trim(IFNULL(bs.LE_ACCOUNT_NAME, '')) &lt;&gt; trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))"));
assertFalse(scopeSql.contains("LE_ACCOUNT_NO"));
assertTrue(annualSql.contains("'EXTERNAL_CERT_NO' AS objectType"));
assertTrue(annualSql.contains("bs.cret_no AS certNo"));
assertTrue(gamblingSql.contains("'EXTERNAL_CERT_NO' AS objectType"));
assertTrue(gamblingSql.contains("having COUNT(1) > 2"));
assertTrue(relationSql.contains("counter_account.owner_type in ('EMPLOYEE', 'RELATION', 'INTERMEDIARY', 'CREDIT_CUSTOMER')"));
assertTrue(relationSql.contains("on counter_account.account_no is null"));
assertTrue(relationSql.contains("counter_staff.name = trim(bs.CUSTOMER_ACCOUNT_NAME)"));
assertTrue(relationSql.contains("counter_relation.relation_name = trim(bs.CUSTOMER_ACCOUNT_NAME)"));
assertTrue(relationSql.contains("counter_account.owner_type in ('EMPLOYEE', 'RELATION')"));
assertFalse(relationSql.contains("customer_cert_no"));
}
private String readXml(String resource) throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(resource)) {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);

View File

@@ -112,6 +112,33 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
}
@Test
void externalPersonSourceSql_shouldIncludeStatementAndObjectHits() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String externalSourceSql = extractSqlFragment(xml, "externalPersonSourceSql");
String normalizedExternalSourceSql = externalSourceSql.replace("\r\n", "\n");
assertTrue(externalSourceSql.contains("tr.bank_statement_id = bs.bank_statement_id"), externalSourceSql);
assertTrue(externalSourceSql.contains("union all"), externalSourceSql);
assertTrue(externalSourceSql.contains("tr.object_type = 'EXTERNAL_CERT_NO'"), externalSourceSql);
assertTrue(externalSourceSql.contains("tr.object_key = subject.cert_no"), externalSourceSql);
assertTrue(externalSourceSql.contains("'资金' as related_object"), externalSourceSql);
assertFalse(externalSourceSql.contains("counter_staff.id_card = bs.customer_cert_no"), externalSourceSql);
assertFalse(externalSourceSql.contains("counter_relation.relation_cert_no = bs.customer_cert_no"), externalSourceSql);
assertFalse(externalSourceSql.contains("counter_intermediary.person_id = bs.customer_cert_no"), externalSourceSql);
assertInOrder(
externalSourceSql,
"when counter_account.owner_type = 'EMPLOYEE' then '员工'",
"when counter_account.owner_type = 'RELATION' then '员工亲属'",
"when counter_account.owner_type = 'CREDIT_CUSTOMER' then '信贷客户'",
"when counter_account.owner_type = 'INTERMEDIARY' then '中介库人员'",
"when counter_staff.id_card is not null then '员工'",
"when counter_relation.relation_cert_no is not null then '员工亲属'"
);
assertTrue(normalizedExternalSourceSql.contains("left join ccdi_base_staff counter_staff\n on counter_account.account_no is null"), externalSourceSql);
assertTrue(normalizedExternalSourceSql.contains("left join ccdi_staff_fmy_relation counter_relation\n on counter_account.account_no is null"), externalSourceSql);
}
@Test
void suspiciousTransactionNameListSql_shouldKeepCreditCustomerAndIntermediaryRulesScopedByAmount() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
@@ -205,4 +232,13 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(endIndex >= 0, "missing closing sql tag: " + sqlId);
return xml.substring(startIndex, endIndex);
}
private void assertInOrder(String sql, String... fragments) {
int previousIndex = -1;
for (String fragment : fragments) {
int currentIndex = sql.indexOf(fragment);
assertTrue(currentIndex > previousIndex, () -> "fragment order mismatch: " + fragment + "\n" + sql);
previousIndex = currentIndex;
}
}
}

View File

@@ -15,7 +15,9 @@ class CcdiProjectSpecialCheckMapperListSqlTest {
String listSql = extractSelect(xml, "selectFamilyAssetLiabilityList");
assertTrue(listSql.contains("order by risk_level_sort desc, comparison_amount desc, staff_name asc"));
assertTrue(xml.contains("from ccdi_bank_statement_tag_result"));
assertTrue(xml.contains("from ccdi_bank_statement bs"));
assertTrue(xml.contains("statement_staff.id_card = trim(bs.cret_no)"));
assertTrue(xml.contains("bs.project_id = #{projectId}"));
assertTrue(xml.contains("ccdi_base_staff"));
assertTrue(xml.contains("ccdi_staff_fmy_relation"));
assertTrue(xml.contains("relation_type = '配偶'"));

View File

@@ -132,6 +132,49 @@ class BankTagRuleConfigResolverTest {
assertEquals("8888", config.getThresholdValue("ANNUAL_TURNOVER"));
}
@Test
void resolve_shouldUseLargeTransactionParamsForExternalLargeTransactionRule() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.selectByProjectAndModel(0L, "LARGE_TRANSACTION")).thenReturn(List.of(
buildParam("LARGE_TRANSACTION", "FREQUENT_TRANSFER", "100000")
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode("EXTERNAL_LARGE_TRANSACTION");
ruleMeta.setRuleCode("EXTERNAL_SINGLE_LARGE_AMOUNT");
ruleMeta.setIndicatorCode("FREQUENT_TRANSFER");
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertEquals("100000", config.getThresholdValue("FREQUENT_TRANSFER"));
}
@Test
void resolve_shouldUseOriginalModelParamsForExternalObjectRules() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.selectByProjectAndModel(0L, "LARGE_TRANSACTION")).thenReturn(List.of(
buildParam("LARGE_TRANSACTION", "CUMULATIVE_TRANSACTION_AMOUNT", "500000"),
buildParam("LARGE_TRANSACTION", "ANNUAL_TURNOVER", "800000")
));
when(modelParamMapper.selectByProjectAndModel(0L, "SUSPICIOUS_GAMBLING")).thenReturn(List.of(
buildParam("SUSPICIOUS_GAMBLING", "MULTI_PARTY_AMT_MIN", "500"),
buildParam("SUSPICIOUS_GAMBLING", "MULTI_PARTY_AMT_MAX", "5000")
));
assertRuleThresholds("EXTERNAL_LARGE_TRANSACTION", "EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT",
Map.of("CUMULATIVE_TRANSACTION_AMOUNT", "500000"));
assertRuleThresholds("EXTERNAL_LARGE_TRANSACTION", "EXTERNAL_ANNUAL_TURNOVER",
Map.of("ANNUAL_TURNOVER", "800000"));
assertRuleThresholds("EXTERNAL_SUSPICIOUS_GAMBLING", "EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER",
Map.of("MULTI_PARTY_AMT_MIN", "500", "MULTI_PARTY_AMT_MAX", "5000"));
}
@Test
void resolve_shouldMapPhaseOneThresholdRulesToUppercaseParamCodes() {
CcdiProject project = new CcdiProject();

View File

@@ -496,6 +496,95 @@ class CcdiBankTagServiceImplTest {
)));
}
@Test
void rebuildProject_shouldDispatchExternalPersonStatementRules() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule largeRule = buildRule("EXTERNAL_LARGE_TRANSACTION", "外部人员大额交易",
"EXTERNAL_SINGLE_LARGE_AMOUNT", "外部人员单笔大额交易", "STATEMENT");
CcdiBankTagRule nightRule = buildRule("EXTERNAL_ABNORMAL_TRANSACTION", "外部人员异常交易",
"EXTERNAL_NIGHT_TRANSACTION", "外部人员夜间集中交易", "STATEMENT");
CcdiBankTagRule gamblingRule = buildRule("EXTERNAL_SUSPICIOUS_GAMBLING", "外部人员可疑赌博",
"EXTERNAL_GAMBLING_MEMO", "外部人员疑似赌博摘要", "STATEMENT");
CcdiBankTagRule relationRule = buildRule("EXTERNAL_SUSPICIOUS_RELATION", "外部人员可疑关系",
"EXTERNAL_TO_STAFF_FAMILY_TRANSACTION", "外部人员与员工或员工亲属交易", "STATEMENT");
BankTagRuleExecutionConfig largeConfig = buildConfig(40L, largeRule);
largeConfig.setThresholdValues(Map.of("FREQUENT_TRANSFER", "100000"));
BankTagStatementHitVO hit = new BankTagStatementHitVO();
hit.setBankStatementId(100L);
hit.setGroupId(40);
hit.setLogId(40001);
hit.setReasonDetail("外部人员命中");
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(largeRule, nightRule, gamblingRule, relationRule));
when(configResolver.resolve(40L, largeRule)).thenReturn(largeConfig);
when(configResolver.resolve(40L, nightRule)).thenReturn(buildConfig(40L, nightRule));
when(configResolver.resolve(40L, gamblingRule)).thenReturn(buildConfig(40L, gamblingRule));
when(configResolver.resolve(40L, relationRule)).thenReturn(buildConfig(40L, relationRule));
when(analysisMapper.selectExternalSingleLargeAmountStatements(40L, new BigDecimal("100000"))).thenReturn(List.of(hit));
when(analysisMapper.selectExternalNightTransactionStatements(40L)).thenReturn(List.of());
when(analysisMapper.selectExternalGamblingMemoStatements(40L)).thenReturn(List.of());
when(analysisMapper.selectExternalToStaffOrFamilyTransactionStatements(40L)).thenReturn(List.of());
service.rebuildProject(40L, null, "admin", TriggerType.MANUAL);
verify(analysisMapper).selectExternalSingleLargeAmountStatements(40L, new BigDecimal("100000"));
verify(analysisMapper).selectExternalNightTransactionStatements(40L);
verify(analysisMapper).selectExternalGamblingMemoStatements(40L);
verify(analysisMapper).selectExternalToStaffOrFamilyTransactionStatements(40L);
verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item ->
"EXTERNAL_LARGE_TRANSACTION".equals(item.getModelCode())
&& "EXTERNAL_SINGLE_LARGE_AMOUNT".equals(item.getRuleCode())
&& "STATEMENT".equals(item.getResultType())
&& Long.valueOf(100L).equals(item.getBankStatementId())
)));
}
@Test
void rebuildProject_shouldDispatchExternalPersonObjectRules() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule cumulativeRule = buildRule("EXTERNAL_LARGE_TRANSACTION", "外部人员大额交易",
"EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT", "外部人员累计交易超限", "OBJECT");
CcdiBankTagRule annualRule = buildRule("EXTERNAL_LARGE_TRANSACTION", "外部人员大额交易",
"EXTERNAL_ANNUAL_TURNOVER", "外部人员年流水交易额超限", "OBJECT");
CcdiBankTagRule gamblingRule = buildRule("EXTERNAL_SUSPICIOUS_GAMBLING", "外部人员可疑赌博",
"EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER", "外部人员同日多对手方疑似赌博交易", "OBJECT");
BankTagRuleExecutionConfig cumulativeConfig = buildConfig(40L, cumulativeRule);
cumulativeConfig.setThresholdValues(Map.of("CUMULATIVE_TRANSACTION_AMOUNT", "500000"));
BankTagRuleExecutionConfig annualConfig = buildConfig(40L, annualRule);
annualConfig.setThresholdValues(Map.of("ANNUAL_TURNOVER", "800000"));
BankTagRuleExecutionConfig gamblingConfig = buildConfig(40L, gamblingRule);
gamblingConfig.setThresholdValues(Map.of("MULTI_PARTY_AMT_MIN", "500", "MULTI_PARTY_AMT_MAX", "5000"));
BankTagObjectHitVO hit = new BankTagObjectHitVO();
hit.setObjectType("EXTERNAL_CERT_NO");
hit.setObjectKey("330100198801010033");
hit.setReasonDetail("外部人员累计交易超限");
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(cumulativeRule, annualRule, gamblingRule));
when(configResolver.resolve(40L, cumulativeRule)).thenReturn(cumulativeConfig);
when(configResolver.resolve(40L, annualRule)).thenReturn(annualConfig);
when(configResolver.resolve(40L, gamblingRule)).thenReturn(gamblingConfig);
when(analysisMapper.selectExternalCumulativeTransactionAmountObjects(40L, new BigDecimal("500000"))).thenReturn(List.of(hit));
when(analysisMapper.selectExternalAnnualTurnoverObjects(40L, new BigDecimal("800000"))).thenReturn(List.of());
when(analysisMapper.selectExternalMultiPartyGamblingTransferObjects(40L, new BigDecimal("500"), new BigDecimal("5000"))).thenReturn(List.of());
service.rebuildProject(40L, null, "admin", TriggerType.MANUAL);
verify(analysisMapper).selectExternalCumulativeTransactionAmountObjects(40L, new BigDecimal("500000"));
verify(analysisMapper).selectExternalAnnualTurnoverObjects(40L, new BigDecimal("800000"));
verify(analysisMapper).selectExternalMultiPartyGamblingTransferObjects(40L, new BigDecimal("500"), new BigDecimal("5000"));
verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item ->
"EXTERNAL_LARGE_TRANSACTION".equals(item.getModelCode())
&& "EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT".equals(item.getRuleCode())
&& "OBJECT".equals(item.getResultType())
&& "EXTERNAL_CERT_NO".equals(item.getObjectType())
&& "330100198801010033".equals(item.getObjectKey())
)));
}
@Test
void buildSafeTaskErrorMessage_shouldKeepLongMessageForLongTextColumn() throws Exception {
Method method = CcdiBankTagServiceImpl.class.getDeclaredMethod(

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
@@ -10,10 +11,12 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.common.exception.ServiceException;
import java.io.File;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -21,6 +24,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -39,6 +43,103 @@ class CcdiProjectOverviewReportPdfExporterTest {
assertTrue(response.getContentAsByteArray().length > 1000);
}
@Test
void shouldUseOverallRiskSummaryInReportMetrics() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(resolveTestFontPath());
CcdiProjectOverviewReportVO report = buildReport();
report.setExternalRiskSummary(buildExternalSummary());
List<CcdiProjectOverviewStatVO> stats = invokeOverallRiskMetrics(exporter, report);
assertEquals(12, stats.get(0).getValue());
assertEquals(3, stats.get(1).getValue());
assertEquals(4, stats.get(2).getValue());
assertEquals(1, stats.get(3).getValue());
assertEquals("无风险", stats.get(4).getLabel());
assertEquals(4, stats.get(4).getValue());
}
@Test
void shouldHideExternalSectionWhenReportHasOnlyEmployees() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(resolveTestFontPath());
MockHttpServletResponse response = new MockHttpServletResponse();
CcdiProjectOverviewReportVO report = buildReport();
exporter.export(response, report);
assertEquals("application/pdf", response.getContentType());
assertFalse(invokeHasExternalRisk(exporter, report));
}
@Test
void shouldShowExternalSectionWhenExternalRiskExists() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(resolveTestFontPath());
MockHttpServletResponse response = new MockHttpServletResponse();
CcdiProjectOverviewReportVO report = buildReport();
report.setExternalRiskSummary(buildExternalSummary());
report.setExternalModelSummaries(List.of(buildExternalModelSummary()));
report.setExternalPersonWarnings(List.of(buildExternalPersonWarning()));
exporter.export(response, report);
List<CcdiProjectOverviewStatVO> externalMetrics = invokeExternalMetrics(exporter, report);
assertEquals("application/pdf", response.getContentType());
assertTrue(invokeHasExternalRisk(exporter, report));
assertEquals("外部人员", externalMetrics.get(0).getLabel());
assertEquals(2, externalMetrics.get(0).getValue());
assertEquals("高风险", externalMetrics.get(1).getLabel());
assertEquals(1, externalMetrics.get(1).getValue());
}
@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);
}
@SuppressWarnings("unchecked")
private List<CcdiProjectOverviewStatVO> invokeOverallRiskMetrics(
CcdiProjectOverviewReportPdfExporter exporter,
CcdiProjectOverviewReportVO report
) throws Exception {
Method method = CcdiProjectOverviewReportPdfExporter.class.getDeclaredMethod(
"buildOverallRiskMetrics",
CcdiProjectOverviewReportVO.class
);
method.setAccessible(true);
return (List<CcdiProjectOverviewStatVO>) method.invoke(exporter, report);
}
@SuppressWarnings("unchecked")
private List<CcdiProjectOverviewStatVO> invokeExternalMetrics(
CcdiProjectOverviewReportPdfExporter exporter,
CcdiProjectOverviewReportVO report
) throws Exception {
Method method = CcdiProjectOverviewReportPdfExporter.class.getDeclaredMethod(
"buildExternalMetrics",
CcdiProjectOverviewReportVO.class
);
method.setAccessible(true);
return (List<CcdiProjectOverviewStatVO>) method.invoke(exporter, report);
}
private boolean invokeHasExternalRisk(
CcdiProjectOverviewReportPdfExporter exporter,
CcdiProjectOverviewReportVO report
) throws Exception {
Method method = CcdiProjectOverviewReportPdfExporter.class.getDeclaredMethod(
"hasExternalRisk",
CcdiProjectOverviewReportVO.class
);
method.setAccessible(true);
return (Boolean) method.invoke(exporter, report);
}
@Test
void shouldRejectBlankPdfFontPath() {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter("");
@@ -60,17 +161,6 @@ 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",
@@ -111,22 +201,33 @@ class CcdiProjectOverviewReportPdfExporterTest {
private CcdiProjectOverviewDashboardVO buildDashboard() {
CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
dashboard.setStats(List.of(
buildStat("总人数", 10),
buildStat("高风险", 2),
buildStat("中风险", 3),
buildStat("低风险", 1),
buildStat("无风险", 4)
buildStat("people", "总人数", 10),
buildStat("riskPeople", "高风险", 2),
buildStat("medium", "中风险", 3),
buildStat("low", "低风险", 1),
buildStat("count", "无风险人员", 4)
));
return dashboard;
}
private CcdiProjectOverviewStatVO buildStat(String label, Integer value) {
private CcdiProjectOverviewStatVO buildStat(String key, String label, Integer value) {
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
stat.setKey(key);
stat.setLabel(label);
stat.setValue(value);
return stat;
}
private CcdiProjectExternalRiskSummaryVO buildExternalSummary() {
CcdiProjectExternalRiskSummaryVO summary = new CcdiProjectExternalRiskSummaryVO();
summary.setTotal(2);
summary.setHigh(1);
summary.setMedium(1);
summary.setLow(0);
summary.setNoRisk(0);
return summary;
}
private CcdiProjectOverviewReportUploadSubjectVO buildUploadSubject() {
CcdiProjectOverviewReportUploadSubjectVO row = new CcdiProjectOverviewReportUploadSubjectVO();
row.setSubjectName("测试主体");
@@ -155,6 +256,15 @@ class CcdiProjectOverviewReportPdfExporterTest {
return row;
}
private CcdiProjectOverviewReportModelSummaryVO buildExternalModelSummary() {
CcdiProjectOverviewReportModelSummaryVO row = new CcdiProjectOverviewReportModelSummaryVO();
row.setModelName("外部人员异常交易");
row.setWarningCount(2);
row.setPeopleCount(2);
row.setPeopleNames("-");
return row;
}
private CcdiProjectRiskModelPeopleItemVO buildRiskPeople() {
CcdiProjectRiskModelPeopleItemVO row = new CcdiProjectRiskModelPeopleItemVO();
row.setStaffName("张三");
@@ -168,6 +278,19 @@ class CcdiProjectOverviewReportPdfExporterTest {
return row;
}
private CcdiProjectExternalPersonWarningExcel buildExternalPersonWarning() {
CcdiProjectExternalPersonWarningExcel row = new CcdiProjectExternalPersonWarningExcel();
row.setName("外部人员甲");
row.setIdNo("330000000000000003");
row.setSubjectType("外部人员");
row.setRiskLevel("高风险");
row.setModelCount(1);
row.setRiskPoint("疑似异常往来");
row.setRelatedObject("张三");
row.setLatestTradeTime("2026-06-25 10:00:00");
return row;
}
private CcdiProjectOverviewReportSuspiciousTransactionVO buildSuspiciousTransaction() {
CcdiProjectOverviewReportSuspiciousTransactionVO row = new CcdiProjectOverviewReportSuspiciousTransactionVO();
row.setTrxDate("2026-03-20 10:00:00");

View File

@@ -17,6 +17,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
@@ -264,17 +265,20 @@ class CcdiProjectOverviewServiceImplTest {
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectSuspiciousTransactionItemVO suspiciousItem = new CcdiProjectSuspiciousTransactionItemVO();
CcdiProjectOverviewReportSuspiciousTransactionVO suspiciousItem =
new CcdiProjectOverviewReportSuspiciousTransactionVO();
suspiciousItem.setTrxDate("2026-03-20 10:00:00");
suspiciousItem.setSuspiciousPersonName("张三");
suspiciousItem.setRelatedPersonName("张三");
suspiciousItem.setLeAccountNo("6222000000000000");
suspiciousItem.setLeAccountName("张三");
suspiciousItem.setCustomerAccountName("测试对手方");
suspiciousItem.setCustomerAccountNo("6222000000000001");
suspiciousItem.setRelatedStaffName("张三");
suspiciousItem.setRelatedStaffCode("1001");
suspiciousItem.setRelationType("本人");
suspiciousItem.setUserMemo("转账");
suspiciousItem.setCashType("转账");
suspiciousItem.setHitTags("异常标签");
suspiciousItem.setDisplayAmount(new BigDecimal("100.00"));
when(overviewMapper.selectSuspiciousTransactionList(any())).thenReturn(List.of(suspiciousItem));
when(overviewMapper.selectReportSuspiciousTransactionList(any())).thenReturn(List.of(suspiciousItem));
CcdiProjectEmployeeCreditNegativeItemVO creditItem = new CcdiProjectEmployeeCreditNegativeItemVO();
creditItem.setPersonName("李四");
@@ -296,14 +300,17 @@ class CcdiProjectOverviewServiceImplTest {
MockHttpServletResponse response = new MockHttpServletResponse();
service.exportRiskDetails(response, 40L);
verify(overviewMapper).selectSuspiciousTransactionList(argThat(query ->
verify(overviewMapper).selectReportSuspiciousTransactionList(argThat(query ->
query.getProjectId().equals(40L) && "ALL".equals(query.getSuspiciousType())
));
verify(workbookExporter).export(
eq(response),
eq(40L),
argThat((List<CcdiProjectSuspiciousTransactionExcel> rows) ->
rows.size() == 1 && "张三".equals(rows.getFirst().getSuspiciousPersonName())
rows.size() == 1
&& "张三".equals(rows.getFirst().getLeAccountName())
&& "测试对手方".equals(rows.getFirst().getCustomerAccountName())
&& "异常标签".equals(rows.getFirst().getHitTags())
),
argThat((List<CcdiProjectEmployeeCreditNegativeExcel> rows) ->
rows.size() == 1 && "李四".equals(rows.getFirst().getPersonName())

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
@@ -98,17 +99,20 @@ class CcdiProjectOverviewServiceSuspiciousTransactionTest {
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectSuspiciousTransactionItemVO item = new CcdiProjectSuspiciousTransactionItemVO();
CcdiProjectOverviewReportSuspiciousTransactionVO item =
new CcdiProjectOverviewReportSuspiciousTransactionVO();
item.setTrxDate("2024-01-15 10:00:00");
item.setSuspiciousPersonName("孙七");
item.setRelatedPersonName("孙七");
item.setLeAccountNo("6222000000000000");
item.setLeAccountName("孙七");
item.setCustomerAccountName("测试对手方");
item.setCustomerAccountNo("6222000000000001");
item.setRelatedStaffName("孙七");
item.setRelatedStaffCode("809901");
item.setRelationType("本人");
item.setUserMemo("");
item.setCashType("转账");
item.setHitTags("大额交易");
item.setDisplayAmount(new BigDecimal("500000.00"));
when(overviewMapper.selectSuspiciousTransactionList(any(CcdiProjectSuspiciousTransactionQueryDTO.class)))
when(overviewMapper.selectReportSuspiciousTransactionList(any(CcdiProjectSuspiciousTransactionQueryDTO.class)))
.thenReturn(List.of(item));
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
@@ -117,8 +121,14 @@ class CcdiProjectOverviewServiceSuspiciousTransactionTest {
List<CcdiProjectSuspiciousTransactionExcel> rows = service.exportSuspiciousTransactions(queryDTO);
assertEquals(1, rows.size());
assertEquals("6222000000000000", rows.getFirst().getLeAccountNo());
assertEquals("孙七", rows.getFirst().getLeAccountName());
assertEquals("测试对手方", rows.getFirst().getCustomerAccountName());
assertEquals("6222000000000001", rows.getFirst().getCustomerAccountNo());
assertEquals("孙七(809901)", rows.getFirst().getRelatedStaffDisplay());
assertEquals("/转账", rows.getFirst().getSummaryAndCashType());
assertEquals("", rows.getFirst().getUserMemo());
assertEquals("转账", rows.getFirst().getCashType());
assertEquals("大额交易", rows.getFirst().getHitTags());
}
@Test

View File

@@ -23,11 +23,14 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
CcdiProjectSuspiciousTransactionExcel suspiciousRow = new CcdiProjectSuspiciousTransactionExcel();
suspiciousRow.setTrxDate("2026-03-20 10:00:00");
suspiciousRow.setSuspiciousPersonName("张三");
suspiciousRow.setRelatedPersonName("张三");
suspiciousRow.setLeAccountNo("6222000000000000");
suspiciousRow.setLeAccountName("张三");
suspiciousRow.setCustomerAccountName("测试对手方");
suspiciousRow.setCustomerAccountNo("6222000000000001");
suspiciousRow.setRelatedStaffDisplay("张三(1001)");
suspiciousRow.setRelationType("本人");
suspiciousRow.setSummaryAndCashType("转账/转账");
suspiciousRow.setUserMemo("转账");
suspiciousRow.setCashType("转账");
suspiciousRow.setHitTags("异常标签");
suspiciousRow.setDisplayAmount(new BigDecimal("100.00"));
CcdiProjectEmployeeCreditNegativeExcel creditRow = new CcdiProjectEmployeeCreditNegativeExcel();
@@ -56,6 +59,19 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
assertEquals("涉疑交易明细", workbook.getSheetAt(0).getSheetName());
assertEquals("员工负面征信信息", workbook.getSheetAt(1).getSheetName());
assertEquals("异常账户人员信息", workbook.getSheetAt(2).getSheetName());
assertEquals("交易时间", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
assertEquals("本方账户", workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue());
assertEquals("本方主体", workbook.getSheetAt(0).getRow(0).getCell(2).getStringCellValue());
assertEquals("对方名称", workbook.getSheetAt(0).getRow(0).getCell(3).getStringCellValue());
assertEquals("对方账户", workbook.getSheetAt(0).getRow(0).getCell(4).getStringCellValue());
assertEquals("关联员工", workbook.getSheetAt(0).getRow(0).getCell(5).getStringCellValue());
assertEquals("摘要", workbook.getSheetAt(0).getRow(0).getCell(6).getStringCellValue());
assertEquals("交易类型", workbook.getSheetAt(0).getRow(0).getCell(7).getStringCellValue());
assertEquals("异常标签", workbook.getSheetAt(0).getRow(0).getCell(8).getStringCellValue());
assertEquals("交易金额", workbook.getSheetAt(0).getRow(0).getCell(9).getStringCellValue());
assertEquals("测试对手方", workbook.getSheetAt(0).getRow(1).getCell(3).getStringCellValue());
assertEquals("6222000000000001", workbook.getSheetAt(0).getRow(1).getCell(4).getStringCellValue());
assertEquals("异常标签", workbook.getSheetAt(0).getRow(1).getCell(8).getStringCellValue());
assertEquals("账号", workbook.getSheetAt(2).getRow(0).getCell(0).getStringCellValue());
assertEquals("开户人", workbook.getSheetAt(2).getRow(0).getCell(1).getStringCellValue());
assertEquals("银行", workbook.getSheetAt(2).getRow(0).getCell(2).getStringCellValue());

View File

@@ -4,6 +4,7 @@ import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.ProjectAccessScope;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
@@ -14,6 +15,7 @@ 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.CcdiProjectAccessService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.domain.response.GetTokenResponse;
@@ -52,6 +54,9 @@ class CcdiProjectServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiProjectAccessService projectAccessService;
@Mock
private CcdiBankTagTaskMapper bankTagTaskMapper;
@@ -79,6 +84,48 @@ class CcdiProjectServiceImplTest {
assertEquals(5L, counts.getStatus4());
}
@Test
void shouldCountDeletedProjectsSeparatelyForManagerScope() {
when(projectAccessService.buildCurrentScope()).thenReturn(
new ProjectAccessScope("manager", true, false, true)
);
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L, 5L);
when(projectMapper.selectDeletedProjectCount(any())).thenReturn(6L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(6L, counts.getStatus5());
}
@Test
void shouldMarkProjectDeletedWithoutCallingBaseDelete() {
when(projectMapper.markProjectDeleted(40L, "tester")).thenReturn(1);
boolean result = service.deleteProject(40L, "tester");
assertTrue(result);
verify(projectAccessService).assertCanDelete(40L);
verify(projectMapper).markProjectDeleted(40L, "tester");
verify(projectMapper, never()).deleteById(40L);
}
@Test
void shouldRestoreDeletedProjectToCompleted() {
when(projectMapper.restoreDeletedProject(40L, "tester")).thenReturn(1);
service.restoreProject(40L, "tester");
verify(projectAccessService).assertCanManageDeletedProjects();
verify(projectMapper).restoreDeletedProject(40L, "tester");
}
@Test
void shouldRejectRestoreWhenProjectIsNotDeleted() {
when(projectMapper.restoreDeletedProject(41L, "tester")).thenReturn(0);
assertThrows(ServiceException.class, () -> service.restoreProject(41L, "tester"));
}
@Test
void shouldReturnLatestFailedTagTaskOnFailedProjectDetail() {
Date endTime = new Date();
@@ -123,6 +170,19 @@ class CcdiProjectServiceImplTest {
() -> service.updateProjectStatus(99L, "3", "system"));
}
@Test
void shouldRejectUpdatingDeletedProjectToTaggingOrCompleted() {
CcdiProject deleted = new CcdiProject();
deleted.setProjectId(99L);
deleted.setStatus("5");
when(projectMapper.selectById(99L)).thenReturn(deleted);
assertThrows(ServiceException.class,
() -> service.updateProjectStatus(99L, "3", "system"));
assertThrows(ServiceException.class,
() -> service.updateProjectStatus(99L, "1", "system"));
}
@Test
void shouldRejectWritingWhenProjectIsTagging() {
CcdiProject tagging = new CcdiProject();
@@ -204,13 +264,13 @@ class CcdiProjectServiceImplTest {
archived.setProjectId(2L);
archived.setStatus("2");
when(projectMapper.selectHistoryProjects(queryDTO)).thenReturn(List.of(completed, archived));
when(projectMapper.selectHistoryProjects(any(), any())).thenReturn(List.of(completed, archived));
List<CcdiProjectHistoryListItemVO> result = service.listHistoryProjects(queryDTO);
assertEquals(2, result.size());
assertEquals(List.of(completed, archived), result);
verify(projectMapper).selectHistoryProjects(queryDTO);
verify(projectMapper).selectHistoryProjects(any(), any());
}
@Test

View File

@@ -18,6 +18,8 @@ class CcdiProjectStatusSqlTest {
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"));
String deletedMigrationSql =
Files.readString(repoRoot.resolve("sql/migration/2026-07-02-add-project-deleted-status.sql"));
assertTrue(initSql.contains("打标中"));
assertTrue(initSql.contains("'3'"));
@@ -34,5 +36,14 @@ class CcdiProjectStatusSqlTest {
assertTrue(tagFailedMigrationSql.contains("'4'"));
assertTrue(tagFailedMigrationSql.contains("latest_task.status = 'FAILED'"));
assertTrue(tagFailedMigrationSql.contains("project.status IN ('0', '3')"));
assertTrue(initSql.contains("已删除"));
assertTrue(initSql.contains("'5'"));
assertTrue(prodInitSql.contains("已删除"));
assertTrue(prodInitSql.contains("'5','ccdi_project_status'"));
assertTrue(deletedMigrationSql.contains("ccdi_project_status"));
assertTrue(deletedMigrationSql.contains("已删除"));
assertTrue(deletedMigrationSql.contains("'5'"));
assertTrue(deletedMigrationSql.contains("utf8mb4_general_ci"));
}
}

View File

@@ -0,0 +1,46 @@
# 外部人员预警后端实施计划
## 目标
在不改变结果总览员工主口径的前提下,新增外部人员预警查询与导出能力,使中介、客户、其他外部人员作为本方流水导入后,也能在结果总览中形成预警结果。
## 实施范围
1. 新增外部人员预警分页接口。
2. 新增外部人员预警导出接口。
3. 新增外部人员模型统计接口。
4. 新增外部人员模型命中人员分页接口。
5. 扩展涉疑交易明细筛选,支持外部人员相关流水。
6. 补充外部人员预警测试数据 SQL。
## 业务口径
1. 本方 `cret_no` 命中员工身份证号时,归入员工。
2. 本方 `cret_no` 命中员工亲属证件号时,归入员工亲属。
3. 本方 `cret_no` 未命中员工和员工亲属时,归入外部人员。
4. 外部人员 `cret_no` 命中中介库本人证件号时,主体类型为中介。
5. 外部人员 `cret_no` 命中信贷客户证件号时,主体类型为客户。
6. 其他外部人员统一显示为外部人员。
7. 外部人员只跑交易和关系类模型,不套用员工资产、负面征信、岗位部门类模型。
## 模型范围
1. `EXTERNAL_LARGE_TRANSACTION`:外部人员大额交易。
2. `EXTERNAL_ABNORMAL_TRANSACTION`:外部人员异常交易。
3. `EXTERNAL_SUSPICIOUS_GAMBLING`:外部人员可疑赌博。
4. `EXTERNAL_SUSPICIOUS_RELATION`:外部人员可疑关系。
## 数据实现
本轮采用直接聚合现有流水和标签结果的最短路径,不新增结果快照表:
1. 外部人员主体来自 `ccdi_bank_statement.cret_no`
2. 命中模型来自 `ccdi_bank_statement_tag_result` 中外部人员模型编码。
3. 与员工或员工亲属的关系来自交易对手证件号、交易对手姓名、账户库命中结果。
4. 中介识别优先使用中介库 `person_id`,不使用姓名作为主体识别依据。
## 验证
1. 执行新增测试数据 SQL。
2. 运行 `mvn test -pl ccdi-project` 相关测试或至少编译 `ccdi-project`
3. 启动后端后验证新增接口返回外部人员预警列表、模型统计和导出接口。

View File

@@ -0,0 +1,29 @@
# 专项分析项目员工范围后端实施计划
## 背景
专项分析原先通过项目打标命中结果解析员工范围。未命中风险规则的项目员工不会展示在专项分析中,无法满足“项目内员工即使未命中也进行专项排查”的业务要求。
## 实施范围
- 修改专项分析公共员工范围 SQL。
- 范围口径调整为:项目已入库银行流水 `ccdi_bank_statement``cret_no` 能匹配员工主数据 `ccdi_base_staff.id_card` 的员工。
- 不新增前端入口、不新增人工纳入表、不伪造打标命中结果。
## 实施步骤
1. 调整 `CcdiProjectSpecialCheckMapper.xml``projectEmployeeScopeSql`
2. 使用 `ccdi_bank_statement.project_id``cret_no` 获取项目员工范围。
3. 关联 `ccdi_base_staff` 获取员工姓名、柜员号、部门。
4. 保持资产负债专项核查、采购拓展、招聘拓展、调动拓展共用同一员工范围。
## 影响范围
- 员工家庭资产负债专项核查列表与详情。
- 专项分析下采购拓展、招聘拓展、调动拓展查询。
- 结果总览风险人员统计不在本次调整范围内,仍按打标结果口径。
## 验证计划
- 执行 Maven 编译,确认 Mapper XML 与 Java 工程可编译。
- 检查 SQL 引用字段存在且口径与项目目标人数回填逻辑一致。

View File

@@ -0,0 +1,22 @@
# 外部人员详情原因快照后端实施计划
## 背景
外部人员「查看详情」的对象异常明细由前端命中标签组装,但命中标签接口未返回 `reasonDetail`,导致对象卡片只能展示模型名和规则名,缺少和员工详情一致的异常原因快照。
## 实施内容
-`CcdiProjectRiskHitTagVO` 增加 `reasonDetail` 字段。
- 在外部人员风险来源 SQL 中透传 `ccdi_bank_statement_tag_result.reason_detail`
-`selectExternalRiskHitTagsByScope` 中按模型和规则聚合命中标签,并优先返回对象级原因快照;对象级为空时返回流水级原因快照。
- 同步在员工命中标签查询中映射 JSON 内的 `reasonDetail`,保持 VO 字段语义一致。
## 影响范围
- 外部人员预警列表详情、命中模型外部人员详情的 `riskPointTagList/hitTagList` 响应字段增加 `reasonDetail`
- 不改变原有筛选、排序、风险等级和模型统计逻辑。
## 验证
- 执行项目风险模型人员与对象明细相关 Mapper 测试。
- 执行后端模块编译或打包,确认 VO 与 XML 映射无编译和解析问题。

View File

@@ -0,0 +1,22 @@
# 资金流向边明细导出后端实施计划(已取消)
## 目标
需求已调整:资金流向逐笔流水明细不再增加“导出明细”,本后端计划不执行。
## 范围
- 不新增资金流向导出 Excel 对象。
- 不新增资金流向导出接口。
- 保持现有 `/ccdi/project/fund-graph/edge-detail` 分页查询不变。
## 实施步骤
1. 回收已准备的资金流向导出前后端改动。
2. 保持资金流向页签仅展示图谱和逐笔流水明细。
3. 将导出需求调整到“查看详情 > 异常明细”组件内实现。
## 验证
- 搜索确认不存在 `/edge-detail/export``exportFundGraphEdgeDetail``CcdiFundGraphStatementExcel`
- 后端不再因本需求产生新增接口。

View File

@@ -0,0 +1,19 @@
# manager 项目权限后端实施计划
## 目标
新增 `manager` 角色并建立统一项目访问控制:`admin` 保持最高权限,`manager` 可查看全部项目但只能操作本人创建的项目,普通角色只能查看和操作本人创建的项目。
## 实施范围
1. 新增 `ProjectAccessScope``CcdiProjectAccessService`,统一提供项目读权限、操作权限、流水/文件/证据等资源反查项目后的权限校验。
2. 项目列表、历史项目列表、状态统计统一走后端 scope`admin/manager` 查询全部,普通角色限定 `create_by = 当前用户名`
3. `CcdiProjectVO` 返回 `ownedByCurrentUser``canOperate`,供前端展示只读态。
4. 项目详情、结果总览、流水明细、专项排查、关系图谱、资金图谱、证据、上传记录、模型参数等接口在服务调用前执行项目读/写权限校验。
5. 项目更新、删除、归档、重新分析、上传、拉取本行信息、文件删除、参数保存、证据保存、手工资金流保存均执行 `assertCanOperate`
6. 资金图谱手工资金流保持现有全局资金图逻辑,不新增 `project_id`,不按项目过滤手工边;保存时仅用当前项目 ID 做操作权限校验。
7. 新增幂等 SQL `sql/migration/2026-07-01-add-manager-role-project-scope.sql`,按 `role_key='manager'` 新增/更新角色并授予项目菜单权限,不自动绑定用户。
## 验证
执行 `mvn -pl ccdi-project -am compile -DskipTests`重点验证权限服务、Controller 注入、Mapper 参数和 MyBatis XML 条件编译通过。

View File

@@ -0,0 +1,19 @@
# 流程列表项目删除与恢复后端实施计划
## 目标
在项目管理后端将删除改为业务逻辑删除,只更新 `ccdi_project` 主表状态与删除标记,不删除项目内上传记录、流水、标签结果、证据等关联数据。管理员范围按 `admin``manager` 执行,普通用户仅允许删除本人创建项目。
## 实施内容
1. 项目状态新增 `5-已删除`,同步常量、实体注释、状态统计 VO、状态文案、初始化 SQL 与迁移 SQL。
2. 项目删除接口保留原路径,入口权限允许拥有项目列表访问的用户进入,业务权限由 `CcdiProjectAccessService` 判断:`admin/manager` 可删除全部,普通用户仅可删除本人创建项目。
3. 删除实现改为 Mapper 专用更新语句,仅设置 `status='5'``del_flag='2'``update_by``update_time`,不调用 MyBatis Plus `deleteById`,不操作关联表。
4. 新增恢复接口 `POST /ccdi/project/{projectId}/restore`,仅 `admin/manager` 可恢复 `status='5' AND del_flag='2'` 的项目,恢复为 `status='1'``del_flag='0'``is_archived=0`
5. 列表查询增加 `includeDeleted`:默认只查 `del_flag='0'` 且排除 `status='5'`;仅管理员查询已删除列表时查 `del_flag='2' AND status='5'`
6. 状态统计增加 `status5`,仅管理员返回已删除数量,普通用户返回 0。
7. 详情、操作、归档、打标状态流转等链路对已删除项目按不可读或不可操作处理,异步状态更新不得覆盖已删除状态。
## 验证范围
后端定向测试覆盖删除只更新项目主表、恢复到已完成、普通入口可进入业务删除校验、默认列表与已删除列表条件、状态字典与迁移 SQL、已删除状态不可被异步打标状态覆盖。

View File

@@ -0,0 +1,47 @@
# 外部人员预警前端实施计划
## 目标
在结果总览页面保持员工为主的展示结构,新增同风格、靠后的“外部人员预警”入口,并补齐列表、详情、模型联动和导出交互。
## 页面结构
1. 风险总览卡片结构不变。
2. 风险人员区域新增弱 Tab
- 员工风险人员
- 外部人员预警
3. 默认选中员工风险人员。
4. 外部人员预警表格不展示工号、部门、资产分析、负面征信等员工专属字段。
5. 外部人员操作按钮显示“查看交易”。
## 外部人员表格字段
1. 姓名。
2. 证件号。
3. 主体类型:中介、客户、外部人员。
4. 风险等级。
5. 命中模型数。
6. 核心异常点。
7. 涉及对象。
8. 最近交易时间。
9. 操作。
## 风险模型
1. 员工风险模型保持现有展示。
2. 在模型区域后置展示外部人员预警模型。
3. 选择外部人员模型时,下方命中人员列表切换为外部人员字段。
4. 导出按钮按当前选中的模型范围导出命中明细。
## 导出
1. 风险人员区域导出按当前 Tab 决定导出员工或外部人员。
2. 外部人员导出文件名为 `风险人员总览_外部人员预警_<项目ID>_<时间>.xlsx`
3. 风险模型导出导出当前筛选模型命中明细。
## 验证
1. 使用真实页面验证员工 Tab 默认展示不变。
2. 切换外部人员预警 Tab确认字段和分页正常。
3. 选择外部人员模型,确认模型命中人员列表切换正常。
4. 点击导出,确认请求触发和文件下载。

View File

@@ -0,0 +1,27 @@
# 外部人员详情加载态修正前端实施计划
## 目标
修正结果总览外部人员“查看详情”弹窗打开时先显示前端拼装的笼统对象异常、再刷新为流水异常明细的问题。
## 实施范围
- 页面:项目详情 > 结果总览 > 外部人员预警 > 查看详情。
- 文件:`ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue`
- 不调整后端接口。
## 实施内容
1. 打开弹窗并加载流水异常明细时,先清空上一轮 `statementRows`
2. `detailLoading``true` 时不渲染 `ProjectAnalysisAbnormalTab`
3. 加载期间仅展示独立加载区域。
4. 流水接口返回后再渲染异常明细内容。
5. 对于未匹配到单笔流水的对象级规则,加载完成后保留“对象异常明细”分组展示。
## 验证要点
1. 进入真实结果总览外部人员预警列表。
2. 点击“查看详情”。
3. 确认加载期间不出现发白的“对象异常明细”内容。
4. 确认加载完成后展示流水异常明细。
5. 确认总流水超限等对象级规则在加载完成后仍可显示为“对象异常明细”。

View File

@@ -0,0 +1,26 @@
# 外部人员预警列表字段语义修正前端实施计划
## 目标
修正结果总览“外部人员预警”列表字段展示语义,避免将模型、规则和涉及对象混用。
## 实施范围
- 页面:项目详情 > 结果总览 > 外部人员预警。
- 文件:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- 不新增接口,不修改后端查询口径。
## 实施内容
1. 移除外部人员列表中的“命中模型数 / 命中模型”列。
2. “核心异常点”继续展示 `riskPointTagList[].ruleName`,即规则名称。
3. “涉及对象”继续展示后端 `relatedObject`,仅表示交易对手方对象类型。
4. 员工风险人员列表不调整。
## 验证要点
1. 进入真实项目详情结果总览。
2. 切换到“外部人员预警”Tab。
3. 确认外部人员列表不再显示“命中模型数 / 命中模型”列。
4. 确认“核心异常点”显示规则名称。
5. 确认“涉及对象”不再承担模型或规则展示语义。

View File

@@ -0,0 +1,21 @@
# 外部人员详情原因快照前端实施计划
## 背景
外部人员详情的「对象异常明细」展示风格已复用员工详情卡片,但内容缺少原因快照,生产页面显示为泛化文案。
## 实施内容
- 外部人员对象卡片优先展示命中标签中的 `reasonDetail`
- 流水标签补全时保留后端返回的 `reasonDetail`
- 历史数据缺少 `reasonDetail` 时,基于已加载的关联流水补充笔数、累计金额、最大金额、最近交易时间、主要对手方,避免只展示泛化命中文案。
## 影响范围
- 仅影响 `ExternalPersonDetailDialog.vue` 中「异常明细」页签的对象异常卡片内容。
- 不调整资金流向、员工详情布局、导出按钮位置和图谱样式。
## 验证
- 使用项目 Node 14.21.3 执行前端构建。
- 启动真实前后端后,在业务页面打开外部人员「查看详情」检查对象异常明细展示。

View File

@@ -0,0 +1,25 @@
# 项目分析异常明细导出前端实施计划
## 目标
在员工风险人员、命中模型涉及人员、外部人员三个“查看详情”入口的“异常明细”页签中增加简洁导出按钮,导出当前弹窗已加载的异常明细数据。
## 范围
- 修改共用组件 `ProjectAnalysisAbnormalTab.vue`
- 导出流水异常与对象异常两类明细。
- 不修改资金流向页签导出能力,资金流向不提供导出按钮。
## 实施步骤
1. 在异常明细页签顶部增加右对齐“导出”按钮。
2. 使用当前 `detailData.groups` 组装 CSV 数据,覆盖分页外的全部已加载记录。
3. 流水异常导出本方名称、本方账号、对手方名称、对手方账号、交易信息、异常标签和金额。
4. 对象异常导出分组、标题、摘要、异常说明、异常标签和补充字段。
5. 使用 UTF-8 BOM 生成 CSV保证 Excel 打开中文正常显示。
## 验证
- 前端构建通过。
- 搜索确认资金流向导出入口不存在。
- 员工与外部人员详情弹窗均复用该异常明细导出按钮。

View File

@@ -0,0 +1,28 @@
# 项目分析详情侧栏收起与异常明细导出前端实施计划
## 目标
在结果总览的项目分析详情弹窗中,减少左侧“人物档案、命中模型摘要”对屏幕宽度的占用,并为“查看详情 > 异常明细”增加导出入口。
## 范围
- 调整 `ProjectAnalysisDialog.vue` 的左右布局,支持左侧侧栏收起和展开。
- 调整 `ProjectAnalysisSidebar.vue` 的紧凑展示样式,使默认占宽更小。
- 调整 `ExternalPersonDetailDialog.vue`,保持外部人员详情与员工详情布局一致。
- 调整 `ProjectAnalysisAbnormalTab.vue`,在异常明细页签提供导出按钮。
- 资金流向页签不提供导出按钮。
## 实施步骤
1. 在项目分析弹窗中维护 `sidebarCollapsed` 状态,弹窗打开时默认展开。
2. 在弹窗布局中加入侧栏收起按钮,收起后左侧变为窄栏,右侧主区域占满剩余空间。
3. 将左侧默认宽度由百分比大侧栏改为固定紧凑宽度,降低对资金流向内容区的挤占。
4. 将弹窗头部压缩为单行低高度展示,去掉“结果总览”层级文案。
5. 在异常明细页签增加“导出”按钮,导出当前弹窗已加载的异常明细数据。
## 验证
- 前端构建或语法检查不报错。
- 真实页面打开项目分析详情后,侧栏可以收起和展开。
- 员工两个“查看详情”和外部人员“查看详情”均使用同一套紧凑布局。
- 异常明细页签可看到导出按钮,资金流向页签不出现导出按钮。

View File

@@ -0,0 +1,18 @@
# manager 项目权限前端实施计划
## 目标
前端根据后端返回的 `canOperate` 呈现项目只读体验:他人项目可查看结果和明细,不展示或禁用上传、参数配置、重新分析、归档、证据确认、手工资金流保存等写入入口。
## 实施范围
1. 项目列表使用 `canOperate` 控制按钮:他人项目仅显示查看入口,重新分析和归档仅在 `canOperate=true` 且状态允许时展示。
2. 他人项目从列表进入详情时默认打开 `overview`;详情页路由指向上传或参数配置时自动切换到结果总览。
3. 详情页将 `canOperate` 传给上传、参数配置、结果总览、证据确认、项目分析图谱等子组件。
4. 上传数据、拉取本行信息、征信导入、文件删除、参数保存、证据确认、手工资金流新增保存均在前端方法入口做只读拦截。
5. 关系图谱、资金图谱、资金边流水明细请求携带当前 `projectId` 作为后端读权限校验上下文;资金图谱手工资金流数据仍保持全局图谱逻辑,不按项目过滤。
6. 新建项目、历史导入和快捷创建入口继续按 `ccdi:project:add` 菜单权限展示。
## 验证
执行 `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && npm run build:prod`,并通过真实浏览器检查列表按钮、详情只读页签、上传/参数/证据/图谱写入入口和查询接口行为。

View File

@@ -0,0 +1,18 @@
# 流程列表项目删除与恢复前端实施计划
## 目标
在流程列表页增加项目删除与恢复操作。默认列表不展示已删除项目;`admin``manager` 可切换到已删除列表并恢复项目;普通用户只能在非删除列表中删除自己有权限删除的项目。
## 实施内容
1. `ruoyi-ui/src/api/ccdiProject.js` 增加 `restoreProject(projectId)`,删除继续复用现有 `delProject(projectId)`
2. 列表页查询参数增加 `includeDeleted`,普通状态 tab 固定传 `includeDeleted=false`;管理员点击“已删除”入口时传 `includeDeleted=true` 且不传普通 `status`
3. 搜索条增加“已删除”tab`admin/manager` 可见,数量使用后端 `status5`
4. 项目表格增加删除和恢复按钮:普通列表中按 `canDelete` 且状态不是 `5` 展示删除;删除列表仅展示恢复按钮,不展示进入项目、查看结果、重新分析、归档。
5. 删除与恢复均使用确认弹窗,删除文案明确项目内数据不会删除,恢复文案明确恢复为已完成状态;成功后刷新列表与统计。
6. 状态颜色补充 `5-已删除` 的危险色展示,字典值由后端 SQL 提供。
## 验证范围
前端源码断言覆盖已删除入口管理员可见、`includeDeleted` 参数、删除/恢复 API 调用、普通列表删除按钮、删除列表仅恢复按钮;构建验证 Vue 模板与打包链路。

View File

@@ -0,0 +1,912 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>结果总览 - 外部人员预警原型</title>
<style>
:root {
--bg: #f5f7fa;
--panel: #ffffff;
--line: #dcdfe6;
--line-soft: #ebeef5;
--text: #303133;
--sub: #606266;
--muted: #909399;
--primary: #2f6fed;
--primary-soft: #eaf1ff;
--danger: #d93026;
--danger-soft: #fdecea;
--warning: #b56a00;
--warning-soft: #fff4df;
--success: #1f7a45;
--success-soft: #e8f5ee;
--orange: #c75c00;
--orange-soft: #fff0e3;
--shadow: 0 8px 22px rgba(31, 45, 61, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--text);
background: var(--bg);
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
font-size: 14px;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
}
.page {
min-height: 100vh;
padding: 18px 24px 28px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.breadcrumbs {
color: var(--muted);
font-size: 13px;
margin-bottom: 6px;
}
h1 {
margin: 0;
font-size: 22px;
line-height: 1.3;
font-weight: 650;
letter-spacing: 0;
}
.project-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
margin-top: 8px;
color: var(--sub);
font-size: 13px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn {
height: 34px;
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 12px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.btn.primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
.btn.ghost {
color: var(--primary);
border-color: #b8cdfd;
background: #fff;
}
.overview {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.metric {
min-height: 86px;
padding: 14px;
background: var(--panel);
border: 1px solid var(--line-soft);
border-radius: 6px;
box-shadow: var(--shadow);
}
.metric-title {
color: var(--sub);
font-size: 13px;
margin-bottom: 10px;
white-space: nowrap;
}
.metric-value {
font-size: 26px;
line-height: 1;
font-weight: 700;
margin-bottom: 8px;
}
.metric-note {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.metric.danger .metric-value {
color: var(--danger);
}
.metric.warning .metric-value {
color: var(--warning);
}
.metric.primary .metric-value {
color: var(--primary);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 14px;
align-items: start;
}
.section {
background: var(--panel);
border: 1px solid var(--line-soft);
border-radius: 6px;
box-shadow: var(--shadow);
margin-bottom: 14px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line-soft);
}
.section-title {
font-size: 16px;
font-weight: 650;
}
.section-body {
padding: 14px 16px 16px;
}
.segmented {
display: inline-grid;
grid-template-columns: repeat(2, minmax(112px, 1fr));
height: 34px;
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
background: #f8f9fb;
}
.segmented button {
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--sub);
cursor: pointer;
padding: 0 12px;
}
.segmented button:last-child {
border-right: 0;
}
.segmented button.active {
background: var(--primary);
color: #fff;
}
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 12px;
}
.field {
height: 34px;
min-width: 148px;
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 10px;
background: #fff;
color: var(--text);
}
.field.search {
min-width: 220px;
flex: 1;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th {
height: 40px;
background: #f5f7fa;
color: var(--sub);
font-weight: 600;
border-bottom: 1px solid var(--line);
text-align: left;
padding: 0 10px;
white-space: nowrap;
}
td {
height: 48px;
border-bottom: 1px solid var(--line-soft);
padding: 8px 10px;
vertical-align: middle;
color: var(--text);
word-break: break-word;
}
tr.selected {
background: #f4f8ff;
}
tr:hover {
background: #f8fbff;
}
.col-name {
width: 15%;
}
.col-type {
width: 12%;
}
.col-cert {
width: 20%;
}
.col-level {
width: 10%;
}
.col-count {
width: 10%;
}
.col-risk {
width: 24%;
}
.col-action {
width: 9%;
text-align: right;
}
.link {
border: 0;
background: transparent;
color: var(--primary);
cursor: pointer;
padding: 0;
}
.tag {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
border: 1px solid transparent;
}
.tag + .tag {
margin-left: 6px;
}
.tag.danger {
color: var(--danger);
background: var(--danger-soft);
border-color: #f5c7c3;
}
.tag.warning {
color: var(--warning);
background: var(--warning-soft);
border-color: #ffd994;
}
.tag.primary {
color: var(--primary);
background: var(--primary-soft);
border-color: #c7d8ff;
}
.tag.success {
color: var(--success);
background: var(--success-soft);
border-color: #b7e0c9;
}
.tag.orange {
color: var(--orange);
background: var(--orange-soft);
border-color: #ffd0a8;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tags .tag {
margin: 0;
}
.models {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.model-item {
min-height: 92px;
border: 1px solid var(--line-soft);
border-radius: 6px;
padding: 12px;
background: #fbfcff;
}
.model-name {
color: var(--sub);
font-size: 13px;
margin-bottom: 10px;
}
.model-count {
font-size: 24px;
line-height: 1;
font-weight: 700;
margin-bottom: 8px;
}
.model-foot {
color: var(--muted);
font-size: 12px;
}
.side-panel {
position: sticky;
top: 14px;
}
.identity {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 14px;
}
.kv {
min-height: 58px;
border: 1px solid var(--line-soft);
border-radius: 5px;
padding: 9px 10px;
background: #fbfcff;
}
.kv-label {
color: var(--muted);
font-size: 12px;
margin-bottom: 6px;
white-space: nowrap;
}
.kv-value {
color: var(--text);
font-size: 14px;
font-weight: 600;
word-break: break-word;
}
.detail-title {
font-weight: 650;
margin: 18px 0 10px;
}
.mini-table th {
height: 34px;
font-size: 12px;
padding: 0 8px;
}
.mini-table td {
height: 42px;
font-size: 12px;
padding: 7px 8px;
}
.risk-text {
color: var(--sub);
line-height: 1.55;
}
.empty-graph {
height: 126px;
border: 1px dashed #c9cdd4;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
background: #fafbfc;
}
.detail-table .col-date {
width: 13%;
}
.detail-table .col-subject {
width: 12%;
}
.detail-table .col-party {
width: 16%;
}
.detail-table .col-labels {
width: 27%;
}
.detail-table .col-amount {
width: 12%;
text-align: right;
}
.amount {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.footer-note {
color: var(--muted);
font-size: 12px;
padding: 0 2px;
}
@media (max-width: 1280px) {
.overview {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid {
grid-template-columns: 1fr;
}
.side-panel {
position: static;
}
}
@media (max-width: 760px) {
.page {
padding: 14px;
}
.topbar,
.section-head {
align-items: flex-start;
flex-direction: column;
}
.toolbar {
width: 100%;
justify-content: flex-start;
}
.overview,
.models,
.identity {
grid-template-columns: 1fr;
}
.field,
.field.search,
.segmented {
width: 100%;
}
table {
min-width: 880px;
}
.table-wrap {
overflow-x: auto;
}
}
</style>
</head>
<body>
<main class="page">
<header class="topbar">
<div>
<div class="breadcrumbs">初核项目 / 流水分析 / 结果总览</div>
<h1>结果总览</h1>
<div class="project-meta">
<span>项目编号HZ20260624001</span>
<span>导入流水18,426 笔</span>
<span>分析批次2026-06-24 09:38</span>
</div>
</div>
<div class="toolbar">
<button class="btn">导出结果</button>
<button class="btn ghost">专项排查</button>
<button class="btn primary">重新分析</button>
</div>
</header>
<section class="overview" aria-label="结果总览统计">
<div class="metric">
<div class="metric-title">分析主体数</div>
<div class="metric-value">126</div>
<div class="metric-note">员工、亲属及外部人员</div>
</div>
<div class="metric danger">
<div class="metric-title">高风险主体</div>
<div class="metric-value">8</div>
<div class="metric-note">较上批次 +2</div>
</div>
<div class="metric warning">
<div class="metric-title">中风险主体</div>
<div class="metric-value">21</div>
<div class="metric-note">待人工复核</div>
</div>
<div class="metric primary">
<div class="metric-title">外部人员预警</div>
<div class="metric-value">12</div>
<div class="metric-note">中介 5 人,其他 7 人</div>
</div>
<div class="metric">
<div class="metric-title">命中模型</div>
<div class="metric-value">17</div>
<div class="metric-note">含外部人员模型 4 个</div>
</div>
<div class="metric">
<div class="metric-title">涉疑交易金额</div>
<div class="metric-value">286.4万</div>
<div class="metric-note">按交易标签汇总</div>
</div>
</section>
<section class="grid">
<div>
<section class="section">
<div class="section-head">
<div class="section-title">风险主体</div>
<div class="segmented" role="tablist" aria-label="风险主体类型">
<button class="active" type="button" data-subject-tab="external">外部人员预警</button>
<button type="button" data-subject-tab="staff">员工风险</button>
</div>
</div>
<div class="section-body">
<div class="filters">
<input class="field search" type="text" value="" placeholder="搜索姓名、证件号、账户" />
<select class="field">
<option>全部主体类型</option>
<option>中介</option>
<option>外部人员</option>
</select>
<select class="field">
<option>全部风险等级</option>
<option>高风险</option>
<option>中风险</option>
<option>低风险</option>
</select>
<button class="btn primary">查询</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-name">风险主体</th>
<th class="col-type">主体类型</th>
<th class="col-cert">证件号</th>
<th class="col-level">风险等级</th>
<th class="col-count">命中模型数</th>
<th class="col-risk">核心风险点</th>
<th class="col-action">操作</th>
</tr>
</thead>
<tbody id="subjectRows">
<tr class="selected" data-name="王某" data-type="中介" data-cert="330************1234" data-level="高风险" data-models="4" data-risk="与员工亲属资金往来、大额转账、特殊金额交易" data-tags="外部人员大额交易,外部人员异常交易,外部人员可疑关系,特殊金额交易">
<td>王某</td>
<td><span class="tag primary">中介</span></td>
<td>330************1234</td>
<td><span class="tag danger">高风险</span></td>
<td>4</td>
<td>与员工亲属资金往来、大额转账、特殊金额交易</td>
<td class="col-action"><button class="link" type="button">详情</button></td>
</tr>
<tr data-name="李某" data-type="外部人员" data-cert="341************6612" data-level="中风险" data-models="2" data-risk="疑似赌博敏感交易、同日多笔转账" data-tags="外部人员异常交易,可疑赌博">
<td>李某</td>
<td><span class="tag orange">外部人员</span></td>
<td>341************6612</td>
<td><span class="tag warning">中风险</span></td>
<td>2</td>
<td>疑似赌博敏感交易、同日多笔转账</td>
<td class="col-action"><button class="link" type="button">详情</button></td>
</tr>
<tr data-name="赵某" data-type="外部人员" data-cert="320************4789" data-level="中风险" data-models="2" data-risk="与员工多次互转、夜间集中交易" data-tags="外部人员异常交易,外部人员可疑关系">
<td>赵某</td>
<td><span class="tag orange">外部人员</span></td>
<td>320************4789</td>
<td><span class="tag warning">中风险</span></td>
<td>2</td>
<td>与员工多次互转、夜间集中交易</td>
<td class="col-action"><button class="link" type="button">详情</button></td>
</tr>
<tr data-name="陈某" data-type="中介" data-cert="362************9021" data-level="低风险" data-models="1" data-risk="单笔交易金额超过外部人员阈值" data-tags="外部人员大额交易">
<td>陈某</td>
<td><span class="tag primary">中介</span></td>
<td>362************9021</td>
<td><span class="tag success">低风险</span></td>
<td>1</td>
<td>单笔交易金额超过外部人员阈值</td>
<td class="col-action"><button class="link" type="button">详情</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="section-head">
<div class="section-title">风险模型</div>
<button class="btn">模型配置</button>
</div>
<div class="section-body">
<div class="models">
<div class="model-item">
<div class="model-name">外部人员大额交易</div>
<div class="model-count">9</div>
<div class="model-foot">单笔或累计金额超过阈值</div>
</div>
<div class="model-item">
<div class="model-name">外部人员异常交易</div>
<div class="model-count">6</div>
<div class="model-foot">频次、时间、金额形态异常</div>
</div>
<div class="model-item">
<div class="model-name">外部人员可疑赌博</div>
<div class="model-count">3</div>
<div class="model-foot">命中赌博敏感交易特征</div>
</div>
<div class="model-item">
<div class="model-name">外部人员可疑关系</div>
<div class="model-count">2</div>
<div class="model-foot">涉及员工或员工亲属资金往来</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="section-head">
<div class="section-title">涉疑交易明细</div>
<button class="btn">查看全部</button>
</div>
<div class="section-body">
<div class="table-wrap">
<table class="detail-table">
<thead>
<tr>
<th class="col-date">交易时间</th>
<th class="col-subject">风险主体</th>
<th>主体类型</th>
<th class="col-party">交易对手</th>
<th>对手类型</th>
<th class="col-labels">异常标签</th>
<th class="col-amount">金额</th>
</tr>
</thead>
<tbody>
<tr>
<td>2026-05-18 10:42</td>
<td>王某</td>
<td><span class="tag primary">中介</span></td>
<td>刘某某</td>
<td><span class="tag warning">员工亲属</span></td>
<td>
<div class="tags">
<span class="tag danger">外部人员可疑关系</span>
<span class="tag warning">特殊金额交易</span>
</div>
</td>
<td class="amount">180,000.00</td>
</tr>
<tr>
<td>2026-05-22 21:16</td>
<td>李某</td>
<td><span class="tag orange">外部人员</span></td>
<td>周某</td>
<td><span class="tag orange">外部人员</span></td>
<td>
<div class="tags">
<span class="tag warning">可疑赌博</span>
<span class="tag primary">夜间集中交易</span>
</div>
</td>
<td class="amount">52,000.00</td>
</tr>
<tr>
<td>2026-06-03 09:27</td>
<td>赵某</td>
<td><span class="tag orange">外部人员</span></td>
<td>张三</td>
<td><span class="tag primary">员工</span></td>
<td>
<div class="tags">
<span class="tag danger">外部人员可疑关系</span>
<span class="tag primary">多次互转</span>
</div>
</td>
<td class="amount">36,800.00</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<aside class="side-panel">
<section class="section">
<div class="section-head">
<div class="section-title">外部人员风险详情</div>
<span class="tag danger" id="detailLevel">高风险</span>
</div>
<div class="section-body">
<div class="identity">
<div class="kv">
<div class="kv-label">风险主体</div>
<div class="kv-value" id="detailName">王某</div>
</div>
<div class="kv">
<div class="kv-label">主体类型</div>
<div class="kv-value" id="detailType">中介</div>
</div>
<div class="kv">
<div class="kv-label">证件号</div>
<div class="kv-value" id="detailCert">330************1234</div>
</div>
<div class="kv">
<div class="kv-label">命中模型数</div>
<div class="kv-value" id="detailModels">4</div>
</div>
</div>
<div class="detail-title">命中标签</div>
<div class="tags" id="detailTags">
<span class="tag danger">外部人员大额交易</span>
<span class="tag warning">外部人员异常交易</span>
<span class="tag primary">外部人员可疑关系</span>
<span class="tag orange">特殊金额交易</span>
</div>
<div class="detail-title">核心风险点</div>
<div class="risk-text" id="detailRisk">与员工亲属资金往来、大额转账、特殊金额交易</div>
<div class="detail-title">关联交易</div>
<table class="mini-table">
<thead>
<tr>
<th>对手方</th>
<th>对手类型</th>
<th>金额</th>
</tr>
</thead>
<tbody>
<tr>
<td>刘某某</td>
<td>员工亲属</td>
<td class="amount">180,000.00</td>
</tr>
<tr>
<td>张三</td>
<td>员工</td>
<td class="amount">36,800.00</td>
</tr>
</tbody>
</table>
<div class="detail-title">关系图谱</div>
<div class="empty-graph">外部人员图谱暂未接入</div>
</div>
</section>
<div class="footer-note">外部人员口径:本方证件号未命中员工及员工亲属,命中中介库本人证件号时标记为中介。</div>
</aside>
</section>
</main>
<script>
const rows = Array.from(document.querySelectorAll("#subjectRows tr"));
const detailName = document.getElementById("detailName");
const detailType = document.getElementById("detailType");
const detailCert = document.getElementById("detailCert");
const detailModels = document.getElementById("detailModels");
const detailRisk = document.getElementById("detailRisk");
const detailLevel = document.getElementById("detailLevel");
const detailTags = document.getElementById("detailTags");
function tagClass(text) {
if (text.includes("大额") || text.includes("可疑关系")) return "danger";
if (text.includes("异常") || text.includes("赌博")) return "warning";
if (text.includes("特殊")) return "orange";
return "primary";
}
function selectRow(row) {
rows.forEach((item) => item.classList.remove("selected"));
row.classList.add("selected");
detailName.textContent = row.dataset.name;
detailType.textContent = row.dataset.type;
detailCert.textContent = row.dataset.cert;
detailModels.textContent = row.dataset.models;
detailRisk.textContent = row.dataset.risk;
detailLevel.textContent = row.dataset.level;
detailLevel.className = "tag " + (row.dataset.level === "高风险" ? "danger" : row.dataset.level === "中风险" ? "warning" : "success");
detailTags.innerHTML = "";
row.dataset.tags.split(",").forEach((text) => {
const tag = document.createElement("span");
tag.className = "tag " + tagClass(text);
tag.textContent = text;
detailTags.appendChild(tag);
});
}
rows.forEach((row) => {
row.addEventListener("click", () => selectRow(row));
});
document.querySelectorAll("[data-subject-tab]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-subject-tab]").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,64 @@
# 外部人员预警实施记录
## 保存路径确认
- 后端实施计划:`docs/plans/backend/2026-06-24-external-person-warning-backend-implementation.md`
- 前端实施计划:`docs/plans/frontend/2026-06-24-external-person-warning-frontend-implementation.md`
- 实施记录:`docs/reports/implementation/2026-06-24-external-person-warning-implementation.md`
- 测试数据 SQL`sql/migration/2026-06-24-add-external-person-warning-test-data.sql`
## 修改内容
1. 结果总览后端新增外部人员预警接口:
- `GET /ccdi/project/overview/external-persons`
- `POST /ccdi/project/overview/external-persons/export`
- `GET /ccdi/project/overview/external-risk-models/cards`
- `GET /ccdi/project/overview/external-risk-models/people`
- `POST /ccdi/project/overview/external-risk-models/people/export`
2. 后端新增外部人员预警 DTO、VO、Excel 导出对象。
3. Mapper 新增外部人员聚合查询,基于本方 `cret_no` 排除员工和员工亲属后识别外部人员。
4. 外部人员主体类型支持中介、客户、外部人员。
5. 风险明细涉疑交易增加 `EXTERNAL_PERSON` 筛选口径。
6. 前端风险人员区域增加靠后的“外部人员预警” Tab员工风险人员仍为默认入口。
7. 前端风险模型区域后置展示外部人员预警模型,模型导出按钮改为真实导出当前筛选命中明细。
8. 新增外部人员预警联调测试数据 SQL。
## 业务口径
1. 员工仍然是结果总览默认主视图,原员工风险人员、员工模型和员工项目分析不变。
2. 外部人员预警和员工使用同一页面风格,但不展示员工专属字段。
3. 外部人员不进入资产分析、负面征信、部门岗位和员工画像详情。
4. 外部人员预警模型限定为交易和关系类:
- 外部人员大额交易
- 外部人员异常交易
- 外部人员可疑赌博
- 外部人员可疑关系
5. 中介识别优先使用中介库本人证件号匹配,不使用姓名作为主体识别依据。
## 验证情况
1. 已执行后端编译:
- `mvn -pl ccdi-project -am compile -DskipTests`
- 结果:通过。
2. 测试数据 SQL 已生成。因本机执行 `bash bin/mysql_utf8_exec.sh sql/migration/2026-06-24-add-external-person-warning-test-data.sql` 失败,已改用 Java/JDBC 显式执行 `SET NAMES utf8mb4``SET collation_connection = utf8mb4_general_ci` 后入库成功。
3. 前端执行前已按项目要求检查 Node
- `.nvmrc``14.21.3`
- 当前 `node -v``v14.21.3`
- 当前环境 `npm` 不在 PATH改用 `C:\Users\20696\AppData\Roaming\nvm\v14.21.3\node.exe node_modules/@vue/cli-service/bin/vue-cli-service.js build`
- 结果:构建通过,仅有既有资源体积 warning。
4. 数据库验证:
- 测试项目 `90624001` 已入库 2 个外部人员主体:测试中介王某、测试客户赵某。
- 外部人员模型命中结果已入库 4 类:外部人员大额交易、外部人员异常交易、外部人员可疑赌博、外部人员可疑关系。
5. 本机已有后端服务监听 `62318`,但未通过脚本重启到最新代码;项目重启脚本依赖 bash/lsof/pgrep当前 Windows 环境缺可用 bash/WSL。
6. 真实接口登录验证受运行环境 Redis 超时影响,`POST /login/test` 返回 `Redis command timed out`,因此未完成浏览器页面验证。
## 后续验证建议
1. 在具备 bash/lsof/pgrep 的环境使用 `bin/restart_java_backend.sh` 重启后端。
2. Redis 恢复后登录系统,在真实结果总览页面验证:
- 员工风险人员默认展示不变。
- 外部人员预警 Tab 可分页加载。
- 外部人员预警导出可下载。
- 外部人员模型卡片后置展示。
- 风险模型导出可按当前筛选下载。
- 风险明细可筛选“外部人员预警”。

View File

@@ -0,0 +1,34 @@
# 外部人员结果总览预警原型实施记录
## 保存路径确认
- 原型文件:`docs/prototypes/external-risk-overview-prototype.html`
- 实施记录:`docs/reports/implementation/2026-06-24-external-risk-overview-prototype.md`
## 修改内容
1. 新增“结果总览 - 外部人员预警”静态 HTML 原型。
2. 在风险主体区域保留原员工风险入口,并增加“外部人员预警”切换入口。
3. 增加外部人员主体列表,字段包含风险主体、主体类型、证件号、风险等级、命中模型数、核心风险点和操作。
4. 增加外部人员模型卡片,覆盖外部人员大额交易、外部人员异常交易、外部人员可疑赌博、外部人员可疑关系。
5. 增加涉疑交易明细展示,体现外部人员与员工、员工亲属、其他外部人员之间的资金往来。
6. 增加右侧外部人员风险详情区,展示身份识别、命中标签、核心风险点、关联交易和暂未接入的外部人员图谱空状态。
## 业务口径
1. 本方证件号未命中员工及员工亲属时,按外部人员进入结果总览预警。
2. 本方证件号命中中介库本人证件号时,主体类型显示为“中介”。
3. 除员工、员工亲属外,其余本方主体统一归入外部人员口径。
4. 涉及员工或员工亲属的外部人员资金往来,纳入“外部人员可疑关系”模型展示。
## 影响范围
- 本次仅新增静态原型和实施记录。
- 未修改 Java 后端、Vue 前端、SQL 脚本或运行配置。
- 不影响现有系统运行逻辑。
## 验证情况
- 已确认目标目录存在。
- 原型为独立 HTML 文件,可直接在浏览器打开查看。
- 本次未启动前后端服务,未执行构建命令。

View File

@@ -0,0 +1,45 @@
# 外部人员详情与整体风险统计实施记录
## 修改时间
2026-06-25
## 修改内容
- 顶部风险总览改为项目整体口径,按员工统计与外部人员预警汇总相加展示。
- 风险总览卡片新增拆分说明,展示“员工 X · 外部 Y”。
- 新增外部人员风险汇总接口 `/ccdi/project/overview/external-persons/summary`,按外部人员高风险、中风险、低风险、无风险返回汇总。
- 外部人员列表与外部风险模型命中人员的操作改为“查看详情”。
- 新增外部人员详情弹窗,包含基本信息、命中模型、交易明细入口、关系图谱空状态、关联对象入口。
- 风险模型区域从左侧窄列表改回上下结构,上方展示紧凑模型统计块,下方展示筛选条件与命中人员表。
- 外部人员详情弹窗改为复用员工项目分析详情的视觉结构,采用标题区、左侧人物档案、右侧页签工作区;外部人员不展示资产分析、征信摘要等员工专属页签。
- 风险模型统计块只对外部模型展示“外部”来源标识,员工模型不再额外展示“员工”标识。
- 外部人员汇总口径改为“导入流水中的外部主体全集”,再左关联外部预警命中结果,避免未命中模型的外部人员被排除在总人数之外。
- 本地前端开发服务补充 history 路由回退配置,避免直接打开 `/login``/ccdiProject/detail/{projectId}` 等路由时返回 404。
- 结果总览一键 PDF 报告补充外部人员内容,包括外部风险汇总、外部模型汇总、外部人员预警明细。
- 外部人员可疑关系关联对象补充“信贷客户”:员工、员工亲属、中介库人员、信贷客户统一优先按对手方账号命中账号库;员工、员工亲属、中介库人员在账号未命中时再按对手方证件号或名称兜底;信贷客户不按名称兜底,只按对手方账号识别。
- 外部人员可疑关系 SQL 剔除本方名称等于对手方名称的交易,避免外部主体本人账户之间互转被识别为关联关系。
## 影响范围
- 前端结果总览:风险总览卡片、风险人员列表、风险模型区域、外部人员详情弹窗。
- 后端结果总览:新增外部人员风险汇总接口与 Mapper 查询。
- 后端外部人员汇总 SQL新增外部主体全集片段外部预警列表仍只展示命中外部模型的人员。
- 后端 PDF 报告导出:新增外部人员预警段落,列表导出与一键报告口径保持一致。
- 外部人员可疑关系展示口径:按外部模型命中流水聚合,关联对象按账号库优先识别员工、员工亲属、中介库人员、信贷客户。
- 暂不改变图谱生成逻辑,图谱入口当前为空状态。
## 验证计划
- 后端编译:`mvn -pl ccdi-project -am compile -DskipTests`
- 前端构建:使用 Node 14.21.3 执行 Vue 构建。
- 本地页面验证:进入真实项目详情页结果总览,确认顶部统计展示员工/外部拆分,外部人员“查看详情”弹窗可打开,交易明细入口可切到流水明细查询,风险模型区域为上下结构。
## 本次追加验证
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests`,编译通过。
- 已使用 `C:\Users\20696\AppData\Roaming\nvm\v14.21.3\node.exe` 执行前端构建,构建通过。
- 前端构建存在既有包体积告警,且 `ruoyi-ui/public` 下临时 docx 文件被打包进 dist该问题不属于本次外部人员展示逻辑变更。
- 已重启本地前端服务至 `http://localhost:8080/`,使用浏览器请求头验证 `/login``/ccdiProject/detail/90624001` 直链返回 200。
- 已再次执行 `mvn -pl ccdi-project -am compile -DskipTests`,验证 PDF 报告外部人员字段编译通过。
- 已再次执行 `mvn -pl ccdi-project -am compile -DskipTests`,验证外部人员可疑关系 SQL 补充信贷客户账号匹配后编译通过。

View File

@@ -0,0 +1,24 @@
# 外部人员预警总览与查看交易修复实施记录
## 修改时间
2026-06-25
## 修改内容
- 修复结果总览“外部人员预警”列表中“查看交易”仅提示、不跳转的问题。
- “查看交易”现在切换到项目详情的“流水明细查询”页签,并按外部人员本方证件号过滤流水。
- 流水明细查询新增 `ourCertNos` 查询条件,对应后端按 `ccdi_bank_statement.cret_no` 过滤。
- 修复风险总览统计口径,顶部“总人数 / 高风险 / 中风险 / 低风险 / 无风险人员”跟随当前风险人员页签展示:员工页签展示员工口径,外部人员预警页签展示外部人员口径。
## 影响范围
- 前端项目详情页:结果总览、流水明细查询页签。
- 后端流水明细查询接口:新增可选查询字段 `ourCertNos`
- 结果总览页面:风险统计卡片会随“员工风险人员 / 外部人员预警”页签切换口径。
## 验证计划
- 后端编译:`mvn -pl ccdi-project -am compile -DskipTests`
- 前端构建:使用 Node 14.21.3 执行 Vue 构建。
- 本地页面验证:进入真实项目详情页,点击外部人员预警“查看交易”,确认切到“流水明细查询”并展示该外部人员本方流水。

View File

@@ -0,0 +1,23 @@
# 风险模型区域紧凑化实施记录
## 修改时间
2026-06-25
## 修改内容
- 将结果总览“风险模型”区域由大卡片网格改为“左侧模型预警统计列表 + 右侧命中人员明细”的紧凑工作区。
- 将模型操作文案由“点击加入联动 / 再次点击取消”调整为“筛选 / 已筛选”,降低业务用户理解成本。
- 将触发方式文案由“任意触发 / 同时触发”调整为“命中任一模型 / 同时命中全部”,明确多模型筛选时的计算含义。
- 新增“当前筛选”展示区,已选模型以标签形式展示,可单个移除或一键清空。
- 保留原有员工模型、外部人员模型、分页、导出、查看明细和模型筛选逻辑,不新增后端接口。
## 影响范围
- 前端项目详情页:结果总览中的风险模型区域。
- 不影响风险人员总览统计、专项排查、流水明细查询和后端模型计算逻辑。
## 验证计划
- 前端构建:使用 Node 14.21.3 执行 Vue 构建。
- 本地页面验证:进入真实项目详情页结果总览,确认风险模型区域展示为紧凑列表,模型筛选、清空、模型关系切换、分页和导出入口可正常使用。

View File

@@ -0,0 +1,47 @@
# 外部人员预警打标规则实施记录
## 1. 修改内容
- 新增外部人员真实打标规则接入,打标任务执行后可自动向 `ccdi_bank_statement_tag_result` 写入 `EXTERNAL_*` 命中结果。
- 新增外部人员主体识别共用 SQL本方证件号 `bs.cret_no` 非空,且未命中员工、员工亲属;若同一主体同时存在于员工和信贷客户/中介数据中,员工/员工亲属优先,不进入外部人员口径;同时排除本方与对手方姓名或账号相同的本人账户互转。
- 新增 4 条流水级规则:
- `EXTERNAL_SINGLE_LARGE_AMOUNT`:外部人员单笔大额交易,复用 `LARGE_TRANSACTION``FREQUENT_TRANSFER` 阈值。
- `EXTERNAL_NIGHT_TRANSACTION`:外部人员夜间交易,识别 22:00 至次日 06:00 的外部主体交易。
- `EXTERNAL_GAMBLING_MEMO`:外部人员疑似赌博摘要,识别摘要、交易类型或对手方名称中的赌博敏感词。
- `EXTERNAL_TO_STAFF_FAMILY_TRANSACTION`:外部人员与员工或员工亲属交易,按对手方账号或对手方名称命中员工/员工亲属,不使用对手方证件号。
- 新增 3 条对象级规则:
- `EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT`:外部人员累计交易金额超限,按外部人员证件号聚合,复用 `LARGE_TRANSACTION``CUMULATIVE_TRANSACTION_AMOUNT` 阈值。
- `EXTERNAL_ANNUAL_TURNOVER`:外部人员近一年流水超限,按外部人员证件号聚合,复用 `LARGE_TRANSACTION``ANNUAL_TURNOVER` 阈值。
- `EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER`:外部人员疑似多人牌局/赌博资金往来,按同一外部人员、同一交易日、多个对手方聚合,复用 `SUSPICIOUS_GAMBLING``MULTI_PARTY_AMT_MIN``MULTI_PARTY_AMT_MAX` 阈值。
- 新增规则元数据迁移脚本:`sql/migration/2026-06-29-add-external-person-bank-tag-rules.sql`
- 打标服务 `CcdiBankTagServiceImpl` 新增外部人员流水级、对象级规则分发。
- 参数解析器 `BankTagRuleConfigResolver` 新增跨模型参数映射:外部人员大额类规则读取 `LARGE_TRANSACTION` 阈值,外部人员疑似赌博对象规则读取 `SUSPICIOUS_GAMBLING` 阈值。
- 风险等级口径调整为:外部人员单笔大额、累计交易超限、年流水交易额超限为中风险;外部人员与员工/亲属交易、同日多对手方疑似赌博交易为高风险。
- 结果总览外部人员来源 SQL 同时纳入流水级命中和对象级命中;对象级命中按外部人员证件号关联,展示涉及对象为“资金”。
- 外部人员对手方关联对象识别调整为账号优先:对手方账号命中信贷客户账号时展示“信贷客户”;对手方账号命中员工/员工亲属账号时展示“员工/员工亲属”;只有对手方账号未命中任何已维护账号时,员工/员工亲属才按对手方名称补充识别。
## 2. 影响范围
- 后端打标链路:`CcdiBankTagServiceImpl``BankTagRuleConfigResolver``CcdiBankTagAnalysisMapper`
- 数据库规则元数据:`ccdi_bank_tag_rule` 新增外部人员 7 条规则。
- 结果总览外部人员预警会消费 `ccdi_bank_statement_tag_result` 中的 `EXTERNAL_*` 流水级与对象级命中结果。
## 3. 验证情况
- 相关单测通过:`mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest,BankTagRuleConfigResolverTest,CcdiProjectOverviewMapperSqlTest" test`,共 54 个用例通过。
- 后端模块编译通过:`mvn -pl ccdi-project -am compile -DskipTests`
- 本地数据库迁移执行成功:`bin/mysql_utf8_exec.sh sql/migration/2026-06-29-add-external-person-bank-tag-rules.sql`
- 查询 `ccdi_bank_tag_rule` 确认 7 条外部人员规则均已落库且 `enabled=1`
- 验证覆盖:
- 外部人员规则 SQL 存在且不是占位 SQL。
- 外部人员关系规则包含对手方账号、名称命中员工或员工亲属的判断,不使用对手方证件号。
- 外部人员总览的对手方关联对象展示优先按账号判断,账号命中信贷客户时不再被同名员工覆盖;员工、员工亲属也是账号优先,账号未命中时才按名称判断。
- 打标服务能分发外部人员流水级和对象级规则。
- 外部人员大额类规则能复用原大额交易阈值。
- 外部人员疑似赌博对象规则能复用原疑似赌博阈值。
- 结果总览外部人员来源能同时包含流水级和对象级命中。
## 4. 后续运行说明
- 真实环境需要先执行 `sql/migration/2026-06-29-add-external-person-bank-tag-rules.sql`,使 7 条外部人员规则进入 `ccdi_bank_tag_rule`
- 执行项目流水打标重算后,外部人员规则会自动生成 `EXTERNAL_*` 命中结果,结果总览外部人员预警即可展示真实命中数据。

View File

@@ -0,0 +1,34 @@
# 外部人员详情页异常明细与资金流向页签调整实施记录
## 1. 修改内容
- 调整 `ExternalPersonDetailDialog.vue`:外部人员详情的“异常明细”复用员工查看详情中的 `ProjectAnalysisAbnormalTab` 展示结构。
- 外部人员异常明细拆分为“流水异常明细”和“对象异常明细”:流水型命中展示具体流水表格,对象型命中仅列出规则、异常原因和摘要。
- 将原“交易明细”跳转入口移除,不再从外部人员详情跳转到流水明细查询。
- 将原“关联对象”页签移除,新增明确的“资金流向”页签,直接展示资金图谱组件 `ProjectAnalysisFundFlowTab`
- 清理 `PreliminaryCheck.vue` 中外部人员详情跳转流水明细的事件绑定和处理方法。
## 2. 影响范围
- 影响页面:项目详情 > 结果总览 > 外部人员预警 > 查看详情。
- 不修改后端接口、SQL 口径、菜单权限和导出接口。
- 原员工风险人员“查看详情”不受影响。
## 3. 验证情况
- 前端生产构建:`npm run build:prod` 通过,仅有既有资源体积 warning。
- 同步调整 `ProjectAnalysisFundFlowTab.vue`:资金流向页签按 `initialGraphTab='fund'` 进入资金布局,支持外部人员详情只展示资金流向能力。
- 本地真实页面地址:`http://localhost:8080/#/ccdiProject/detail/90624001?tab=overview`
## 4. 二次调整
- 外部人员“异常明细”不再把每个模型做成主体字段卡片。
- 新增按外部人员证件号加载流水明细,命中流水型外部模型的记录展示在“流水异常明细”表格中。
- 只有能在外部人员流水明细命中标签中匹配到的规则进入“流水异常明细”;没有对应具体流水的对象型规则进入“对象异常明细”,例如年流水超限。
- 对象型外部模型不展示主体字段堆叠,只在“对象异常明细”中列出模型规则、异常原因和摘要。
- 外部人员详情仍只保留“异常明细”和“资金流向”两个页签;员工专属的资产、征信等模块不在外部人员详情中展示。
## 5. 三次调整
- 删除外部人员详情弹窗头部右侧“分析主体 xxx”信息块避免与左侧人物档案重复展示。
- 在外部人员详情头部右侧增加关闭 `X`,点击后关闭详情弹窗并返回列表。

View File

@@ -0,0 +1,25 @@
# 外部人员详情加载态修正实施记录
## 修改内容
- 修正外部人员“查看详情”弹窗打开时先显示前端拼装的“对象异常明细”兜底内容的问题。
- 加载流水异常明细前先清空 `statementRows`
- `detailLoading``true` 时不渲染异常明细组件,仅展示独立加载区域。
- 流水接口返回后再渲染真实异常明细内容。
- 加载完成后,未匹配到单笔流水的对象级规则仍保留为“对象异常明细”分组。
## 影响范围
- 影响页面:项目详情 > 结果总览 > 外部人员预警 > 查看详情。
- 影响文件:`ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue`
- 不影响员工风险人员详情弹窗。
- 不调整后端接口。
## 验证情况
- 已完成静态链路验证:
- `detailLoading``true` 时不再挂载 `ProjectAnalysisAbnormalTab`
- 加载前会清空 `statementRows`,避免沿用上一轮流水数据。
- 加载期间只显示 `.external-detail-loading` 独立加载区域。
- `abnormalDetailData` 只在加载完成后计算;未匹配到单笔流水的 `riskTags` 仍会生成 `EXTERNAL_OBJECT_WARNING` 对象异常分组。
- 真实页面验证受当前结果总览接口状态限制:刷新 `http://localhost:8080/ccdiProject/detail/90624001` 后页面进入“暂无结果总览数据”,无法稳定打开外部人员详情做端到端复核。

View File

@@ -0,0 +1,32 @@
# 外部人员资金流向页签与预警 Tab 显示实施记录
## 修改内容
- 外部人员详情“资金流向”页签改为使用与员工“资金流向”一致的图谱配置。
- 移除外部人员资金流向页签只展示资金单页签的限制,恢复资金图谱组件默认的资金与关系图谱能力。
- 当外部人员预警数量为 0 时结果总览风险人员区域不再显示“外部人员预警”Tab。
- 若当前停留在外部人员 Tab 后切换到无外部预警项目自动回到“员工风险人员”Tab。
- 结果总览 PDF 报告中,单纯存在无风险外部主体不再触发“外部人员预警”段落;只有存在外部高中低风险命中、外部模型汇总或外部预警明细时才输出该段落。
## 影响范围
- 影响页面:项目详情 > 结果总览 > 外部人员预警 > 查看详情 > 资金流向。
- 影响页面:项目详情 > 结果总览 > 员工风险人员 / 外部人员预警 Tab。
- 影响 PDF项目详情 > 结果总览 > 导出报告。
- 不调整后端接口、SQL 口径和列表导出接口。
## 验证情况
- 已新增本轮临时测试数据脚本 `output/browser-use/2026-06-29-external-person-multi-subject-test.sql`,覆盖 2 名员工、2 名中介、1 名客户、1 名无关人员;该脚本不提交 Git。
- 已导入多主体测试数据并调用接口验证:
- `/ccdi/project/overview/risk-people?projectId=90629002&pageNum=1&pageSize=10` 返回员工风险人员 2 人。
- `/ccdi/project/overview/external-persons?projectId=90629002&pageNum=1&pageSize=10` 返回外部人员预警 3 人,包含 2 名中介、1 名客户。
- 无关人员未出现在外部人员预警列表中。
- 已新增本轮临时无预警测试数据脚本 `output/browser-use/2026-06-29-external-person-no-warning-test.sql`,该脚本不提交 Git。
- 已导入无预警测试数据并调用接口验证:
- `/ccdi/project/overview/external-persons?projectId=90629003&pageNum=1&pageSize=10` 返回外部人员预警 0 条。
- `/ccdi/project/overview/external-persons/summary?projectId=90629003` 返回 `total=1``noRisk=1``high=0``medium=0``low=0`
- 该场景用于验证存在无风险外部主体但无外部预警命中时前端不显示“外部人员预警”TabPDF 不输出“外部人员预警”段落。
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests`,后端编译通过。
- 已使用 `C:\Users\20696\AppData\Roaming\nvm\v14.21.3\node.exe` 执行前端生产构建,构建通过;存在既有资源体积 warning 和 `public` 下临时 docx 被打包 warning。
- `browser-use` 插件要求的 Node 执行工具当前未暴露,且本地 `npx`/Playwright 依赖不可用;本次真实浏览器自动化验证未完成,已用接口、编译和组件逻辑完成替代验证。

View File

@@ -0,0 +1,21 @@
# 外部人员预警验收文档与验证实施记录
## 1. 修改内容
- 新增外部人员预警结果总览验收清单:`docs/tests/plans/2026-06-29-external-person-warning-acceptance-checklist.md`
- 新增外部人员预警结果总览测试用例:`docs/tests/plans/2026-06-29-external-person-warning-test-cases.md`
- 后续测试执行记录保存到:`docs/tests/records/2026-06-29-external-person-warning-verification-record.md`
## 2. 影响范围
- 本次仅新增测试计划、测试用例与验证记录文档。
- 不修改业务代码、SQL 口径、菜单、权限或构建配置。
- 验收重点为:外部人员预警、空身份证号排除、外部人员详情、流水明细证件号联动、导出与报告内容。
## 3. 验证计划
- 后端执行 `mvn -pl ccdi-project -am compile -DskipTests`
- 前端命令执行前先尝试 `nvm use` 并记录 Node 版本。
- 使用本地真实服务验证外部人员预警接口。
- 使用 `browser-use` 打开真实项目详情页结果总览,不打开原型页。
- 测试完成后关闭本轮启动的前后端进程。

View File

@@ -0,0 +1,21 @@
# 外部人员预警列表字段语义修正实施记录
## 修改内容
- 移除外部人员预警列表中的“命中模型数 / 命中模型”列。
- “核心异常点”继续展示 `riskPointTagList[].ruleName`
- “涉及对象”继续展示后端 `relatedObject`,用于表达交易对手方对象类型。
## 影响范围
- 影响页面:项目详情 > 结果总览 > 外部人员预警。
- 影响文件:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- 不影响员工风险人员列表。
- 不新增后端接口,不调整后端查询口径。
## 验证情况
- 已使用真实页面验证 `http://localhost:8080/ccdiProject/detail/90624001`
- 切换到结果总览“外部人员预警(2)”Tab。
- 表头保留“核心异常点 / 涉及对象”,不再显示“命中模型数 / 命中模型”列。
- “核心异常点”展示规则名称,如“外部人员夜间集中交易、外部人员单笔大额交易、外部人员与员工亲属交易”。

View File

@@ -0,0 +1,31 @@
# 导出报告风险总览口径修复实施记录
## 修改时间
2026-06-29
## 修改内容
- 修复结果总览“导出报告”PDF 中风险总览指标仍使用员工单侧口径的问题。
- PDF 顶部风险指标改为“总人数 = 高风险 + 中风险 + 低风险 + 外部人员”口径。
- PDF 第三章标题由“风险模型”调整为“风险总览”,避免标题与指标内容不一致。
- 当项目没有外部人员风险统计、外部模型汇总和外部人员预警明细时PDF 不再输出“外部人员预警”空段落。
- 不在 PDF 中额外展示“员工 X / 外部 Y”拆分说明风险总览五项指标为总人数、高风险、中风险、低风险、外部人员。
- 同步调整结果总览页面顶部统计卡,删除“员工 X · 外部 Y”拆分说明并将“无风险人员”替换为“外部人员”。
- 删除流水明细查询页标题下的“按项目范围查询交易明细并查看详情”备注。
## 影响范围
- 后端结果总览 PDF 报告导出器。
- 后端 PDF 导出单元测试。
- 前端结果总览统计卡。
- 前端流水明细查询标题区。
## 验证情况
- 已执行 `mvn -pl ccdi-project -DskipTests compile`,后端主代码编译通过。
- 已执行 `mvn -pl ccdi-project -Dtest=CcdiProjectOverviewReportPdfExporterTest test`,主代码编译通过,测试编译阶段被当前工作区既有的涉疑交易导出字段测试阻塞;阻塞点为 `CcdiProjectSuspiciousTransactionExcel` 已切换到新字段,而部分既有测试仍引用 `setSuspiciousPersonName``getSummaryAndCashType` 等旧字段。
- 已使用 `C:\Users\20696\AppData\Roaming\nvm\v14.21.3\node.exe` 执行前端生产构建,构建通过。
- 前端构建存在既有包体积告警,且 `ruoyi-ui/public` 下临时 docx 文件被打包进 dist该问题不属于本次统计口径调整。
- 已通过 `Invoke-WebRequest http://localhost:8080/` 确认当前前端服务返回 200。
- 浏览器自动化验证受环境限制未完成Chrome DevTools 通道被当前本机 Chrome profile 占用,终端 Playwright 前置 `npx` 不可用;未关闭用户现有浏览器进程。

View File

@@ -0,0 +1,77 @@
# 项目分析功能清单
## 范围说明
本清单汇总项目详情页结果总览、外部人员预警、风险明细导出和专项分析相关能力,包含近期已保存的外部人员相关改动,以及 2026-06-29 沟通中确认的小调整。
## 外部人员预警
- 结果总览支持展示“外部人员预警”列表。
- 支持按外部人员主体展示姓名、证件号、主体类型、风险等级、风险次数、核心异常点和涉及对象。
- 核心异常点展示命中规则名称,不再展示“命中模型数 / 命中模型”列。
- 涉及对象展示交易对手方对象类型,用于区分外部人员关联对象语义。
- 外部人员预警列表与员工风险人员列表在同一区域分 Tab 展示。
- 当外部人员预警数量为 0 时不显示“外部人员预警”Tab。
- 后端保留外部人员预警列表导出能力,但前端列表右上角的小型导出入口已移除。
## 外部人员详情
- 外部人员预警支持“查看详情”。
- 详情弹窗展示外部人员基础信息和风险摘要。
- 详情页只保留“异常明细”和“资金流向”两个页签。
- 异常明细复用项目分析异常明细结构。
- 流水型命中展示在“流水异常明细”中,包含具体流水记录和命中标签。
- 对象型命中展示在“对象异常明细”中,仅展示规则、异常原因和摘要。
- 详情加载期间清空上一轮流水数据,只展示加载态,避免先显示前端拼装的兜底异常明细。
- 详情头部已移除重复的“分析主体”信息块,保留右上角关闭入口。
## 外部人员资金流向页签
- 外部人员详情新增“资金流向”页签。
- 资金流向页签使用与员工“资金流向”一致的资金图谱配置。
- 外部人员详情不展示员工专属的资产、征信等页签。
- 原“关联对象”页签和“交易明细”跳转入口已移除。
## 结果总览与报告
- 结果总览顶部统计卡将“无风险人员”调整为“外部人员”。
- 统计卡不再额外展示“员工 X / 外部 Y”拆分说明。
- 导出报告 PDF 顶部风险指标按“高风险 + 中风险 + 低风险 + 外部人员”汇总。
- PDF 第三章标题调整为“风险总览”。
- 当项目没有外部人员风险统计、外部模型汇总和外部人员预警明细时PDF 不输出外部人员预警空段落。
- 单纯存在无风险外部主体但没有外部预警命中时PDF 不输出“外部人员预警”段落。
- 结果总览顶部“导出报告”入口保留。
## 风险明细导出
- 涉疑交易明细导出字段已对齐流水明细查询导出结构。
- 导出字段为:交易时间、本方账户、本方主体、对方名称、对方账户、关联员工、摘要、交易类型、异常标签、交易金额。
- 风险明细整体导出继续包含“涉疑交易明细 / 员工负面征信信息 / 异常账户人员信息”三个工作表。
- 单独涉疑交易导出和风险明细整体导出中的涉疑交易工作表使用同一字段结构。
## 风险人员列表入口清理
- 移除结果总览“风险人员”区域 Tab 右侧的小型“导出”按钮。
- 保留结果总览顶部“导出报告”入口。
- 保留后端员工风险人员和外部人员预警导出接口。
## 专项分析
- 专项分析位于项目详情页,用于围绕项目员工开展员工家庭资产负债核查和业务拓展查询。
- 员工范围口径已调整为项目流水员工:`ccdi_bank_statement.project_id` 匹配当前项目,且流水 `cret_no` 能关联到 `ccdi_base_staff.id_card`
- 调整后,项目流水中存在的员工即使没有打标命中,也会进入专项分析范围。
- 员工家庭资产负债专项核查展示员工本人及配偶的收入、资产、负债汇总,并按核查结果展示正常、存在风险、高风险、缺少信息。
- 专项分析包含资金图谱入口。
- 拓展查询包含采购拓展、招聘拓展和调动拓展。
## 验收与测试文档
- 已保存外部人员预警验收清单。
- 已保存外部人员预警测试用例。
- 后续验证记录路径已规划到 `docs/tests/records/`
## 未改变的口径
- 员工风险人员结果总览仍按打标命中结果统计。
- 打标命中结果表和风险人数统计不因专项分析范围调整而改变。
- 负面征信和异常账户查询逻辑不在本清单调整范围内。

View File

@@ -0,0 +1,38 @@
# 项目分析页 UI 与接口排查实施记录
## 修改时间
2026-06-29
## 修改范围
- 员工预警查看详情弹窗
- 外部人员预警详情弹窗
- 结果总览风险模型命中人员列表
- 本地结果总览相关接口排查
## 修改内容
1. 员工预警查看详情弹窗右上角补充关闭按钮,交互与外部人员详情弹窗保持一致。
2. 外部人员预警详情页签文案由“资金”调整为“资金流向”。
3. 风险模型区域在未筛选特定模型时,默认展示全部模型命中人员:
- 员工模型命中人员与外部人员模型命中人员合并展示;
- 表格增加“来源”列区分员工和外部;
- 未筛选模型时不显示部门筛选,避免仅员工维度条件影响全部列表;
- 选中员工模型或外部模型后,仍按对应模型类型调用原有分页接口。
4. 本地接口排查结果:
- 登录、风险总览、员工风险人员、风险模型卡片、员工模型命中人员、外部模型命中人员、涉疑交易、征信负面、外部人员汇总接口均返回 200
- 报告导出接口可成功生成 PDF
- 报告导出实测耗时超过前端普通请求 10 秒超时阈值,已将通用下载请求超时时间调整为 120 秒;
- 日志中历史报错主要为客户端断开连接、导出超时和 Druid 慢 SQL 按 ERROR 级别输出,并非当前接口不可用。
## 影响范围
- 前端页面行为变更限于项目详情结果总览内的弹窗、页签文案、风险模型人员列表和通用下载超时时间。
- 后端接口本次未修改。
## 验证情况
- 已通过本地 HTTP 调用验证核心结果总览接口均返回成功。
- 已验证报告导出生成 PDF 文件。
- 已查看前端开发服务日志,最新编译成功。

View File

@@ -0,0 +1,22 @@
# 结果总览人员列表导出入口移除实施记录
## 修改内容
- 移除结果总览“风险人员”区域 Tab 右侧的小型“导出”按钮。
- 删除前端组件中对应的 `handleRiskPeopleExport` 方法。
- 保留顶部“导出报告”入口,项目级 PDF 报告导出不受影响。
- 保留后端员工风险人员与外部人员预警列表导出接口,本次不做接口删除。
## 影响范围
- 影响页面:项目详情 > 结果总览 > 员工风险人员 / 外部人员预警。
- 影响文件:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- 不影响:结果总览顶部“导出报告”、风险模型导出、风险明细导出、后端导出接口。
## 验证情况
- 已静态搜索确认前端组件内不再存在人员列表导出按钮调用。
- 已使用真实页面验证 `http://localhost:8080/ccdiProject/detail/90342?tab=overview`
- 顶部“导出报告”入口仍正常展示。
- “员工风险人员 / 外部人员预警”Tab 正常展示。
- 风险人员列表右上角不再显示小型“导出”入口。

View File

@@ -0,0 +1,19 @@
# 专项分析项目员工范围实施记录
## 修改内容
- 将专项分析公共员工范围从“项目打标命中员工”调整为“项目流水对应员工”。
- 修改文件:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectSpecialCheckMapper.xml`
- 员工范围来源改为 `ccdi_bank_statement`,通过 `project_id``cret_no` 关联 `ccdi_base_staff.id_card`
## 影响范围
- 未命中打标规则但在项目流水中存在的员工,会进入专项分析。
- 专项分析下资产负债核查、采购拓展、招聘拓展、调动拓展共用调整后的员工范围。
- 不改变打标结果表和结果总览风险人员汇总口径。
## 验证情况
- 已核对银行流水实体 `CcdiBankStatement` 包含 `projectId``cretNo` 字段。
- 已核对项目目标人数修复脚本使用相同口径:银行流水身份证号匹配员工主数据。
- 已执行 `mvn -pl ccdi-project -am compile`,编译通过。

View File

@@ -0,0 +1,24 @@
# 涉疑交易明细导出字段调整实施记录
## 1. 修改内容
- 调整涉疑交易明细导出字段,改为对齐“流水明细查询”导出结构。
- 导出字段变更为:交易时间、本方账户、本方主体、对方名称、对方账户、关联员工、摘要、交易类型、异常标签、交易金额。
- 涉疑交易导出复用报告涉疑交易查询,确保导出包含本方/对方账户信息和异常标签。
- 结果总览风险明细页顶部“导出”按钮继续调用风险明细整体导出接口,仍导出“涉疑交易明细 / 员工负面征信信息 / 异常账户人员信息”三个工作表。
- 风险明细整体导出中的“涉疑交易明细”工作表同步使用新字段。
## 2. 影响范围
- 影响页面:项目详情 > 结果总览 > 风险明细 > 导出。
- 影响接口:`/ccdi/project/overview/risk-details/export``/ccdi/project/overview/suspicious-transactions/export` 的涉疑交易字段结构。
- 不修改涉疑交易页面分页查询口径,不修改负面征信和异常账户查询逻辑。
## 3. 验证情况
- 后端模块编译:`mvn -pl ccdi-project -am compile -DskipTests` 通过;存在既有 `ccdi-info-collection` 重复依赖 warning。
- 前端生产构建:`npm run build:prod` 通过;存在既有资源体积 warning 和 `public` 下临时 docx 被打包 warning。
- 应用打包:`mvn -pl ruoyi-admin -am package -DskipTests` 通过。
- 本地后端已使用新包重启,端口 `62318` 当前监听进程为新启动的 Java 进程。
- 实际调用 `/ccdi/project/overview/risk-details/export` 下载 Excel确认“涉疑交易明细”表头为交易时间、本方账户、本方主体、对方名称、对方账户、关联员工、摘要、交易类型、异常标签、交易金额同时保留“员工负面征信信息”“异常账户人员信息”两个工作表。
- 实际调用 `/ccdi/project/overview/suspicious-transactions/export` 下载 Excel确认单独涉疑交易导出表头同样为上述新字段。

View File

@@ -0,0 +1,25 @@
# 2026-06-30 流水本方证件号清洗实施记录
## 背景
业务核对发现,业务侧上传文件名存在“身份证号-姓名”的不规范写法,流水平台返回的本方证件号也可能沿用该形式,且横线前可能存在空格。该值落库到 `ccdi_bank_statement.cret_no` 后,会导致结果总览按身份证号匹配员工、亲属或外部主体时无法命中。
## 修改内容
- 修改 `CcdiBankStatement.fromResponse`,在流水平台响应转换为本地流水实体时清洗 `cretNo`
- 清洗规则:
- 先去除首尾空格。
- 将全角横线、长横线、短横线统一视为半角横线。
- 如存在横线,仅保留横线前的身份证号部分,并再次去除首尾空格。
- 新增单元测试覆盖半角横线和全角横线场景。
## 影响范围
- 仅影响后续新拉取或新上传后落库的流水。
- 不修改表结构、接口协议、打标规则和前端页面。
- 改动范围很小,只在流水接口响应转换为本地实体时做字段清洗。
- 已经落库的历史脏数据需要单独清洗 `ccdi_bank_statement.cret_no` 后重新打标,才能恢复结果总览命中。
## 验证
- 运行 `mvn -pl ccdi-project "-Dtest=CcdiBankStatementTest" test`

View File

@@ -0,0 +1,295 @@
# 2026-06-30 单提交生产发布功能清单
## 1. 发布范围
本清单说明本地分支 `dev-ui` 相对远程 `origin/dev-ui` 的单提交发布内容。
本次生产发布建议以当前本地 `HEAD` 单提交 `完善外部人员预警与项目分析上线内容` 为准,准确提交号以 `git log -1 --oneline` 输出为准。
对比基线:
- 远程基线:`origin/dev-ui`,当前提交为 `4e90e22e 优化项目详情资金流向图谱展示`
- 本地提交:当前本地 `HEAD`,提交说明为 `完善外部人员预警与项目分析上线内容`
- 提交数量1 个。
- 文件范围76 个文件覆盖后端、前端、SQL、测试与实施文档。
## 2. 本次提交改了哪些内容
### 2.1 后端接口与领域对象
改动范围:
- 修改 `CcdiProjectOverviewController`,新增外部人员预警、外部风险模型卡片、外部模型命中人员和导出入口。
- 新增外部人员查询 DTO、外部风险模型命中人员 DTO。
- 新增外部人员预警列表 VO、汇总 VO、外部风险汇总 VO。
- 新增外部人员预警导出对象、风险模型命中人员导出对象。
- 调整涉疑交易明细导出对象,补齐流水基础字段和关联员工字段。
实现结果:
- 结果总览具备外部人员预警接口能力。
- 外部人员模型命中人员可以分页查询和导出。
- 风险明细导出的“涉疑交易明细”更接近流水明细查询导出格式。
### 2.2 后端 SQL 与服务逻辑
改动范围:
- 修改 `CcdiProjectOverviewMapper.xml`,新增外部人员识别、汇总、列表、外部模型、外部模型命中人员 SQL。
- 修改 `CcdiBankTagAnalysisMapper.xml`,新增外部人员规则打标 SQL。
- 修改 `CcdiBankStatementMapper.xml`,支持按本方证件号查询流水。
- 修改 `CcdiProjectSpecialCheckMapper.xml`,调整专项分析员工范围。
- 修改 `BankTagRuleConfigResolver``CcdiBankTagServiceImpl`,接入外部人员规则参数和执行分发。
- 修改 `CcdiProjectOverviewServiceImpl`组装外部人员预警、风险模型、详情导出、PDF 数据。
- 修改 `CcdiProjectRiskDetailWorkbookExporter``CcdiProjectOverviewReportPdfExporter`
实现结果:
- 外部人员以流水本方证件号 `bs.cret_no` 识别。
- 外部主体必须证件号非空,且不是员工、不是员工亲属。
- 外部主体再匹配中介库、信贷客户库,区分“中介 / 客户 / 外部人员”。
- 外部人员对象级命中和流水级命中都能进入结果总览。
- 专项分析员工范围改为项目流水覆盖员工,而不是只看打标命中员工。
### 2.3 前端页面与交互
改动范围:
- 新增 `ExternalPersonDetailDialog.vue`
- 修改结果总览入口 `PreliminaryCheck.vue`
- 修改风险人员区 `RiskPeopleSection.vue`
- 修改风险模型区 `RiskModelSection.vue`
- 修改项目分析弹窗、侧栏、资金流向 Tab、流水明细查询标题等组件。
- 修改 `projectOverview.js`,补充外部人员相关 API。
实现结果:
- 结果总览风险人员区域新增“外部人员预警”页签。
- 外部人员详情在当前页面弹窗打开,不再跳转流水明细查询。
- 外部人员详情只保留“异常明细”和“资金流向”两个页签。
- 外部人员没有员工专属的资产、征信等模块。
- 风险模型区域同时支持员工模型和外部人员模型。
- 修复外部模型重复渲染导致的 Vue duplicate key 问题。
### 2.4 测试、文档和 SQL
改动范围:
- 新增或调整后端单测,覆盖外部人员 SQL、规则参数、服务分发、PDF、Excel 导出。
- 新增后端和前端实施计划、实施记录、验收记录。
- 新增外部风险总览原型文档。
- 新增外部人员规则生产 SQL。
- 新增外部人员预警测试数据 SQL。
实现结果:
- 提交内包含可追溯的实施文档和验收记录。
- 生产 SQL 和测试 SQL 已拆开说明,避免生产误执行测试数据。
## 3. 做了什么
### 3.1 专项分析员工范围调整
做了:
- 将专项分析的员工范围从“项目打标命中员工”调整为“项目流水对应员工”。
- 通过 `ccdi_bank_statement.project_id` 和流水本方证件号 `cret_no` 关联 `ccdi_base_staff.id_card`
实现了:
- 项目流水中存在的员工,即使没有命中风险模型,也能进入专项分析范围。
- 员工家庭资产负债、采购拓展、招聘拓展、调动拓展使用同一项目员工范围。
- 不改变结果总览风险人员统计口径,不改变打标结果表。
### 3.2 外部人员识别与预警
做了:
- 新增外部人员识别逻辑。
- 外部人员主体口径为:流水 `bs.cret_no` 非空,且该证件号不是员工、不是员工亲属。
- 新增外部人员预警汇总、列表、模型卡片、模型命中人员等后端查询能力。
实现了:
- 结果总览可以分析员工以外的中介、信贷客户、其他外部主体。
- 外部人员可以按“中介 / 客户 / 外部人员”等主体类型展示。
- 仅有无风险外部主体时,不展示外部人员预警空 Tab不在 PDF 里输出外部人员空段落。
- 对象级外部人员命中也能进入结果总览统计。
### 3.3 外部人员 7 条流水规则
做了:
- 新增 SQL 迁移脚本:`sql/migration/2026-06-29-add-external-person-bank-tag-rules.sql`
- 新增 7 条外部人员流水打标规则,并接入后端打标服务分发。
实现了以下规则:
1. 外部人员单笔大额交易:外部主体单笔交易金额超过大额阈值。
2. 外部人员累计交易超限:外部主体累计交易金额超过累计阈值。
3. 外部人员年流水交易额超限:外部主体近一年流水交易额超过年累计阈值。
4. 外部人员夜间集中交易:外部主体在 22:00 至次日 06:00 发生交易。
5. 外部人员疑似赌博摘要:摘要、交易类型或对手方名称命中赌博敏感词。
6. 外部人员同日多对手方疑似赌博交易:同日多笔、多对手方、金额位于疑似赌博区间。
7. 外部人员与员工或员工亲属交易:外部主体与员工或员工亲属发生资金往来。
规则口径:
- 外部人员大额类规则复用已有 `LARGE_TRANSACTION` 参数。
- 外部人员疑似赌博对象规则复用已有 `SUSPICIOUS_GAMBLING` 参数。
- 外部人员单笔大额、累计交易超限、年流水交易额超限为中风险;外部人员与员工/亲属交易、同日多对手方疑似赌博交易为高风险。
- 不使用对手方证件号判断,因为流水没有可靠的对方证件号。
- 外部人员与信贷客户/中介交易时,优先按对手方账号识别。
- 外部人员与员工/亲属交易时,优先按对手方账号识别;账号没有命中已维护账号时,才按对手方名称匹配员工或亲属。
- 如果对手方账号命中信贷客户,即使名称与员工相同,也按信贷客户展示。
### 3.4 结果总览页面
做了:
- 结果总览增加外部人员统计。
- 风险人员区域增加“外部人员预警”页签。
- 风险模型区域同时展示员工模型和外部人员模型。
- 修复外部模型重复渲染导致的 Vue duplicate key 问题。
实现了:
- 顶部统计卡保持 dev-ui 原结构,展示总人数、高风险、中风险、低风险、无风险。
- 顶部统计卡数值按员工与外部主体合并计算:总人数等于员工总人数加外部总人数,高中低和无风险分别加上外部对应人数。
- 当外部总人数大于 0 时,统计卡显示“员工 X / 外部 Y”拆分小字外部总人数为 0 时不显示拆分小字。
- 外部人员预警列表展示:姓名、证件号、主体类型、风险等级、核心异常点、涉及对象、最近交易时间。
- 涉及对象显示业务词,如员工、员工亲属、中介库人员、信贷客户、资金。
- 不再展示不清晰的“关联对象”文案。
- 风险模型区可以筛选员工模型和外部模型,并查看命中人员。
### 3.5 外部人员详情
做了:
- 新增外部人员详情弹窗。
- 外部人员详情只保留“异常明细”和“资金流向”两个页签。
- 右上角提供关闭按钮。
- 移除右上角重复的“分析主体xxx”。
- 移除“跳转流水明细查询”的交互。
实现了:
- 点击外部人员“查看详情”后,在当前结果总览内打开详情弹窗。
- 异常明细按员工详情的异常明细方式展示。
- 流水型异常展示流水明细。
- 对象型异常展示对象命中原因,例如年流水超限。
- 资金流向页签使用与员工资金流向一致的资金图谱能力。
### 3.6 涉疑交易明细与导出
做了:
- 调整风险明细工作簿中“涉疑交易明细”sheet 的字段。
- 保留“员工负面征信信息”和“异常账户人员信息”sheet。
实现了:
- 风险明细导出包含 3 个 sheet
- 涉疑交易明细。
- 员工负面征信信息。
- 异常账户人员信息。
- “涉疑交易明细”字段为:
- 交易时间。
- 本方账户。
- 本方主体。
- 对方名称。
- 对方账户。
- 关联员工。
- 摘要。
- 交易类型。
- 异常标签。
- 交易金额。
### 3.7 PDF 报告
做了:
- 调整结果总览 PDF 的风险汇总展示。
- 增加外部人员相关汇总输出。
- 避免无外部风险时输出空的外部人员段落。
实现了:
- PDF 顶部风险指标按总人数、高风险、中风险、低风险、无风险汇总,并合并外部人员对应风险等级。
- PDF 第三章标题为“风险总览”。
- 存在外部人员风险时,输出外部人员风险汇总、外部模型汇总、外部人员预警明细。
- 不存在外部风险命中时,不输出外部人员预警空段落。
### 3.8 页面交互细节
做了:
- 移除流水明细查询页头部多余副标题。
- 移除风险人员区域 Tab 右侧的小型导出入口。
- 保留结果总览顶部“导出报告”入口。
- 修复项目分析弹窗和外部人员详情交互中的细节问题。
实现了:
- 页面更贴合实际业务操作路径。
- 外部人员详情不会引导用户跳到流水明细查询。
- 外部人员没有员工专属的资产、征信等模块。
## 4. 包含的接口能力
本次代码中涉及或完善的接口能力包括:
- 外部人员预警列表:`/ccdi/project/overview/external-persons`
- 外部人员预警汇总:`/ccdi/project/overview/external-persons/summary`
- 外部风险模型卡片:`/ccdi/project/overview/external-risk-models/cards`
- 外部风险模型命中人员:`/ccdi/project/overview/external-risk-models/people`
- 外部风险模型命中人员导出:`/ccdi/project/overview/external-risk-models/people/export`
- 风险明细导出:`/ccdi/project/overview/risk-details/export`
- 涉疑交易明细查询和导出仍保留现有入口,并补齐导出字段。
## 5. 包含的数据库脚本
生产需要执行:
- `sql/migration/2026-06-29-add-external-person-bank-tag-rules.sql`
该脚本用于写入外部人员 7 条流水打标规则,支持重复执行更新规则口径。
提交内还包含测试数据脚本:
- `sql/migration/2026-06-24-add-external-person-warning-test-data.sql`
该脚本只用于本地或测试环境造数验证,生产不要执行。
本次提交不包含表结构变更,不需要新增业务表、系统表、菜单表或字典表。
## 6. 不包含的内容
本次提交不包含以下内容:
- 不提交 `tongweb_62318.properties`,该文件包含本机路径。
- 不提交 `ruoyi-admin/src/main/resources/application-dev.yml` 中未确认的 `allow-circular-references: true`
- 不提交随机 `.docx` 文件。
- 不提交 `ruoyi-ui/public/*.docx`,这些文件会污染前端生产 `dist`
- 不提交历史 5 月文档、SQL 和原型图片。
- 不改变员工风险人员结果总览的打标统计口径。
- 不改变负面征信和异常账户的业务规则。
## 7. 验证结论
已完成以下验证:
- 后端外部人员规则、SQL、服务分发、导出、PDF 相关单元测试通过。
- 后端针对性单测共 13 个测试类、82 个用例0 失败、0 错误。
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- `mvn -pl ruoyi-admin -am package -DskipTests` 通过。
- 前端使用 Node `22.22.3` 执行 `npm run build:prod` 通过。
- 真实页面登录并进入项目 `90629002` 验证通过:
- 结果总览可打开。
- 外部人员预警 Tab 可展示。
- 外部人员详情弹窗可打开。
- 异常明细在详情弹窗内展示。
- 风险模型区域无 duplicate key 控制台错误。
- 风险明细导出实测通过,导出的 xlsx 包含 3 个 sheet涉疑交易明细字段符合本清单。
结论:本地已提交的业务功能可以作为生产发布候选;发布时必须使用干净提交产物,不要使用当前脏工作区直接打包。

View File

@@ -0,0 +1,34 @@
# 2026-06-30 上线前验收与风险模型重复 Key 修复实施记录
## 1. 修改背景
上线前总体验收时,真实页面进入项目结果总览后,浏览器控制台出现 `RiskModelSection` 的 Vue duplicate key 错误,重复 key 包括 `EXTERNAL_ABNORMAL_TRANSACTION``EXTERNAL_LARGE_TRANSACTION``EXTERNAL_SUSPICIOUS_GAMBLING``EXTERNAL_SUSPICIOUS_RELATION`
## 2. 修改内容
- `RiskModelSection.vue`
- 员工模型卡片列表中过滤 `EXTERNAL_` 开头的外部模型。
- 外部模型仍由 `/external-risk-models/cards` 专门接口加载。
- 避免同一个外部模型同时出现在员工模型卡片和外部模型卡片中,消除重复 key。
## 3. 影响范围
- 仅影响结果总览中的风险模型卡片渲染。
- 不调整模型统计接口、不调整外部人员识别规则、不调整导出字段。
## 4. 验证情况
- 后端针对性单测13 个测试类、82 个用例全部通过。
- 后端编译:`mvn -pl ccdi-project -am compile -DskipTests` 通过。
- 主应用打包:`mvn -pl ruoyi-admin -am package -DskipTests` 通过。
- 前端构建:`npm run build:prod` 通过。
- 浏览器真实页面验收:
- 项目 `90629002` 进入结果总览成功。
- 风险总览、外部人员预警、风险模型、涉疑交易明细可见。
- 外部人员详情弹窗可打开,右上角关闭按钮可见,异常明细在弹窗内展示。
- 修复后控制台 0 error、0 warning。
## 5. 遗留风险
- `ruoyi-ui/public/` 下存在 3 个随机 `.docx` 文件,会被复制进 `dist`,生产打包前必须移除或排除。
- `tongweb_62318.properties` 包含本机路径,不应进入生产提交。

View File

@@ -0,0 +1,23 @@
# 外部人员详情对象异常原因快照实施记录
## 修改内容
- 后端 `CcdiProjectRiskHitTagVO` 增加 `reasonDetail` 字段。
- 后端外部人员风险命中标签查询返回 `reason_detail`,并优先取对象级原因快照。
- 前端外部人员详情对象异常卡片优先展示真实 `reasonDetail`
- 前端增加缺失原因快照时的流水统计兜底展示,包含关联流水、累计金额、最大金额、最近交易和主要对手方。
- 前端外部人员预警列表标准化命中标签时保留 `ruleCode``reasonDetail`,避免从列表进入详情时丢失原因快照。
## 影响范围
- 外部人员预警详情。
- 命中模型涉及外部人员的「查看详情」。
- 员工详情原有对象明细接口不变,仅命中标签 VO 支持原因字段。
## 验证情况
- 后端 Mapper 测试通过:`CcdiProjectOverviewMapperRiskModelPeopleTest``CcdiProjectOverviewMapperSqlTest` 共 10 个用例通过。
- 前端生产构建通过,存在项目既有包体积告警。
- 后端 `ruoyi-admin` 打包通过并已重启到 62318。
- 前端已用 Node 14.21.3 重启到 8080。
- 真实页面验证通过:外部人员详情「对象异常明细」已展示详细原因快照,例如“近一年流水交易额 52000.00 元,超过阈值 10000 元”,不再显示泛化命中文案。

View File

@@ -0,0 +1,20 @@
# 资金流向图谱图例与节点分类展示优化实施记录
## 修改内容
- 将资金流向图谱右上角图例由“人员、家庭关系、对手方、已展开”调整为“本人、关系人、其他方、已展开”。
- 将关系人节点从与本人相近的绿色人物三角改为橙色六边形人物节点,降低本人和关系人之间的视觉混淆。
- 在资金图谱节点 tooltip 中增加“类型”说明,显示本人、关系人、其他方或已展开。
## 影响范围
- 前端文件:`ruoyi-ui/src/views/ccdiProject/components/detail/graph/FundGraphSection.vue`
- 仅调整资金图谱展示文案、节点图标与 tooltip不修改接口、后端逻辑、SQL 或资金流向数据口径。
## 验证情况
- 已通过代码检查确认分类逻辑保持不变:中心节点仍为本人,有关系类型的节点仍归为关系人,已展开节点仍按展开状态展示,其余节点归为其他方。
- 已执行 `nvm use 14.21.3`,当前 Node 版本为 `v14.21.3`
- 已执行 `node node_modules\@vue\cli-service\bin\vue-cli-service.js build --mode production`,构建成功;仅存在既有资源体积 warning。
- 已在本地前端服务 `http://localhost:8080/` 进入真实项目详情页面,打开项目分析详情并切换至资金流向页签,资金图谱 canvas 正常渲染,页面无前端报错。
- 已检查资金图谱配置代码:图例为“本人、关系人、其他方、已展开”,关系人节点使用橙色六边形人物图标,节点 tooltip 输出“类型”说明。

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