diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java index 33f1c08e..e7c2d334 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java @@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery 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.CcdiProjectRiskExclusionSaveDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel; @@ -33,8 +34,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; @@ -67,6 +70,18 @@ public class CcdiProjectOverviewController extends BaseController { return AjaxResult.success(dashboard); } + /** + * 排除单条可疑预警 + */ + @PostMapping("/risk-exclusions") + @Operation(summary = "排除单条可疑预警") + @PreAuthorize("@ss.hasPermi('ccdi:project:query')") + public AjaxResult excludeRisk(@Validated @RequestBody CcdiProjectRiskExclusionSaveDTO dto) { + projectAccessService.assertCanOperate(dto.getProjectId()); + overviewService.excludeRisk(dto); + return AjaxResult.success("排除成功"); + } + /** * 查询风险人员总览 */ diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskExclusionSaveDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskExclusionSaveDTO.java new file mode 100644 index 00000000..150e6061 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskExclusionSaveDTO.java @@ -0,0 +1,36 @@ +package com.ruoyi.ccdi.project.domain.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 项目结果页排除可疑保存DTO + */ +@Data +public class CcdiProjectRiskExclusionSaveDTO { + + /** 项目ID */ + @NotNull(message = "项目ID不能为空") + private Long projectId; + + /** 人员证件号 */ + private String staffIdCard; + + /** 规则编码 */ + @NotBlank(message = "规则编码不能为空") + private String ruleCode; + + /** 排除类型:STATEMENT/OBJECT */ + @NotBlank(message = "排除类型不能为空") + private String exclusionType; + + /** 流水ID */ + private Long bankStatementId; + + /** 排除原因 */ + @NotBlank(message = "排除原因不能为空") + @Size(max = 1000, message = "排除原因不能超过1000个字符") + private String excludeReason; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectRiskExclusion.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectRiskExclusion.java new file mode 100644 index 00000000..b583d3fa --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectRiskExclusion.java @@ -0,0 +1,58 @@ +package com.ruoyi.ccdi.project.domain.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 项目结果页排除可疑记录 + */ +@Data +@TableName("ccdi_project_risk_exclusion") +public class CcdiProjectRiskExclusion implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 主键ID */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 项目ID */ + private Long projectId; + + /** 人员证件号 */ + private String staffIdCard; + + /** 规则编码 */ + private String ruleCode; + + /** 排除类型:STATEMENT/OBJECT */ + private String exclusionType; + + /** 流水ID */ + private Long bankStatementId; + + /** 排除原因 */ + private String excludeReason; + + /** 创建者 */ + private String createBy; + + /** 创建时间 */ + private Date createTime; + + /** 更新者 */ + private String updateBy; + + /** 更新时间 */ + private Date updateTime; + + /** 备注 */ + private String remark; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectPersonAnalysisObjectRecordVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectPersonAnalysisObjectRecordVO.java index 6df7a53a..d37a53d7 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectPersonAnalysisObjectRecordVO.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectPersonAnalysisObjectRecordVO.java @@ -12,6 +12,8 @@ public class CcdiProjectPersonAnalysisObjectRecordVO { private String modelCode; + private String ruleCode; + private String title; private String subtitle; diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectRiskExclusionMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectRiskExclusionMapper.java new file mode 100644 index 00000000..ad3bce34 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectRiskExclusionMapper.java @@ -0,0 +1,27 @@ +package com.ruoyi.ccdi.project.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.ccdi.project.domain.entity.CcdiProjectRiskExclusion; +import org.apache.ibatis.annotations.Param; + +/** + * 项目结果页排除可疑记录 Mapper + */ +public interface CcdiProjectRiskExclusionMapper extends BaseMapper { + + /** + * 幂等写入排除记录 + * + * @param exclusion 排除记录 + * @return 写入条数 + */ + int upsertExclusion(@Param("exclusion") CcdiProjectRiskExclusion exclusion); + + /** + * 查询待排除命中是否存在 + * + * @param exclusion 排除记录 + * @return 命中数量 + */ + int countExistingRiskHit(@Param("exclusion") CcdiProjectRiskExclusion exclusion); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java index 53952046..3512385a 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java @@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery 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.CcdiProjectRiskExclusionSaveDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; @@ -42,6 +43,13 @@ public interface ICcdiProjectOverviewService { */ CcdiProjectOverviewDashboardVO getDashboard(Long projectId); + /** + * 排除单条可疑预警 + * + * @param dto 排除参数 + */ + void excludeRisk(CcdiProjectRiskExclusionSaveDTO dto); + /** * 查询风险人员总览 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java index aaa12823..78ef7f28 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java @@ -10,6 +10,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery 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.CcdiProjectRiskExclusionSaveDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; @@ -18,6 +19,7 @@ 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; +import com.ruoyi.ccdi.project.domain.entity.CcdiProjectRiskExclusion; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO; @@ -57,14 +59,18 @@ 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.mapper.CcdiProjectRiskExclusionMapper; +import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants; import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; import jakarta.servlet.http.HttpServletResponse; import jakarta.annotation.Resource; import java.io.IOException; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -80,6 +86,10 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi private static final String ACTION_LABEL = "查看详情"; + private static final String EXCLUSION_TYPE_STATEMENT = "STATEMENT"; + + private static final String EXCLUSION_TYPE_OBJECT = "OBJECT"; + @Resource private CcdiProjectOverviewMapper overviewMapper; @@ -98,6 +108,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi @Resource private CcdiBankTagResultMapper bankTagResultMapper; + @Resource + private CcdiProjectRiskExclusionMapper riskExclusionMapper; + @Resource private CcdiProjectOverviewEmployeeResultBuilder overviewEmployeeResultBuilder; @@ -133,6 +146,24 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi return dashboard; } + @Override + @Transactional(rollbackFor = Exception.class) + public void excludeRisk(CcdiProjectRiskExclusionSaveDTO dto) { + CcdiProjectRiskExclusion exclusion = buildRiskExclusion(dto); + CcdiProject project = getRequiredProject(exclusion.getProjectId()); + if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus()) || Integer.valueOf(1).equals(project.getIsArchived())) { + throw new ServiceException("已归档项目暂不允许排除可疑"); + } + if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) { + throw new ServiceException("项目正在打标中,暂不允许排除可疑"); + } + if (riskExclusionMapper.countExistingRiskHit(exclusion) <= 0) { + throw new ServiceException("未找到可排除的预警命中"); + } + riskExclusionMapper.upsertExclusion(exclusion); + refreshOverviewEmployeeResults(exclusion.getProjectId(), exclusion.getUpdateBy()); + } + @Override public CcdiProjectRiskPeopleOverviewVO getRiskPeopleOverview(CcdiProjectRiskPeopleQueryDTO queryDTO) { Long projectId = queryDTO.getProjectId(); @@ -188,6 +219,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi queryDTO.getStaffIdCard() )); attachStatementHitTags(statementRows, queryDTO.getProjectId()); + statementRows = statementRows.stream() + .filter(row -> row.getHitTags() != null && !row.getHitTags().isEmpty()) + .toList(); normalizeObjectRows(objectRows); CcdiProjectPersonAnalysisDetailVO detail = new CcdiProjectPersonAnalysisDetailVO(); @@ -608,6 +642,60 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi getRequiredProject(projectId); } + private CcdiProjectRiskExclusion buildRiskExclusion(CcdiProjectRiskExclusionSaveDTO dto) { + if (dto == null) { + throw new ServiceException("排除参数不能为空"); + } + String exclusionType = normalizeExclusionType(dto.getExclusionType()); + String ruleCode = normalizeRequired(dto.getRuleCode(), "规则编码不能为空").toUpperCase(); + String excludeReason = normalizeRequired(dto.getExcludeReason(), "排除原因不能为空"); + if (excludeReason.length() > 1000) { + throw new ServiceException("排除原因不能超过1000个字符"); + } + CcdiProjectRiskExclusion exclusion = new CcdiProjectRiskExclusion(); + exclusion.setProjectId(dto.getProjectId()); + exclusion.setRuleCode(ruleCode); + exclusion.setExclusionType(exclusionType); + exclusion.setExcludeReason(excludeReason); + exclusion.setStaffIdCard(normalizeBlankToNull(dto.getStaffIdCard())); + exclusion.setBankStatementId(dto.getBankStatementId()); + if (EXCLUSION_TYPE_STATEMENT.equals(exclusionType) && exclusion.getBankStatementId() == null) { + throw new ServiceException("流水型排除必须指定流水ID"); + } + if (EXCLUSION_TYPE_OBJECT.equals(exclusionType) && (exclusion.getStaffIdCard() == null || exclusion.getStaffIdCard().isBlank())) { + throw new ServiceException("对象型排除必须指定人员证件号"); + } + String operator = SecurityUtils.getUsername(); + Date now = new Date(); + exclusion.setCreateBy(operator); + exclusion.setCreateTime(now); + exclusion.setUpdateBy(operator); + exclusion.setUpdateTime(now); + return exclusion; + } + + private String normalizeExclusionType(String exclusionType) { + String type = normalizeRequired(exclusionType, "排除类型不能为空").toUpperCase(); + if (!EXCLUSION_TYPE_STATEMENT.equals(type) && !EXCLUSION_TYPE_OBJECT.equals(type)) { + throw new ServiceException("排除类型不正确"); + } + return type; + } + + private String normalizeRequired(String value, String message) { + if (value == null || value.isBlank()) { + throw new ServiceException(message); + } + return value.trim(); + } + + private String normalizeBlankToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + private void normalizeRiskModelPeopleQuery(CcdiProjectRiskModelPeopleQueryDTO queryDTO) { if (queryDTO.getMatchMode() == null || queryDTO.getMatchMode().isBlank()) { queryDTO.setMatchMode("ANY"); diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml index 5299ef35..c4a14b81 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml @@ -55,6 +55,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ccdi_bank_statement_tag_result where project_id = #{projectId} and bank_statement_id is not null + and not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = ccdi_bank_statement_tag_result.project_id + and ex.rule_code = ccdi_bank_statement_tag_result.rule_code + and ex.exclusion_type = 'STATEMENT' + and ex.bank_statement_id = ccdi_bank_statement_tag_result.bank_statement_id + ) and bank_statement_id IN #{item} diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewEmployeeResultMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewEmployeeResultMapper.xml index 30fe9af8..5b250fa9 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewEmployeeResultMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewEmployeeResultMapper.xml @@ -79,6 +79,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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 + and not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = tr.project_id + and ex.rule_code = tr.rule_code + and ( + (ex.exclusion_type = 'STATEMENT' + and ex.bank_statement_id = tr.bank_statement_id) + or (ex.exclusion_type = 'OBJECT' + and ex.staff_id_card = coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card)) + ) + ) diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml index 076abb30..155a7680 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml @@ -187,6 +187,18 @@ on relation.person_id = family_staff.id_card where tr.project_id = #{projectId} and coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) is not null + and not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = tr.project_id + and ex.rule_code = tr.rule_code + and ( + (ex.exclusion_type = 'STATEMENT' + and ex.bank_statement_id = tr.bank_statement_id) + or (ex.exclusion_type = 'OBJECT' + and ex.staff_id_card = coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card)) + ) + ) @@ -662,6 +674,14 @@ 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, '')) + and not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = tr.project_id + and ex.rule_code = tr.rule_code + and ex.exclusion_type = 'STATEMENT' + and ex.bank_statement_id = tr.bank_statement_id + ) union all @@ -689,6 +709,14 @@ and tr.object_type = 'EXTERNAL_CERT_NO' and tr.object_key = subject.cert_no and tr.model_code in + where not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = tr.project_id + and ex.rule_code = tr.rule_code + and ex.exclusion_type = 'OBJECT' + and ex.staff_id_card = subject.cert_no + ) @@ -972,6 +1000,14 @@ from ccdi_bank_statement_tag_result tr where tr.project_id = #{query.projectId} and tr.bank_statement_id is not null + and not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = tr.project_id + and ex.rule_code = tr.rule_code + and ex.exclusion_type = 'STATEMENT' + and ex.bank_statement_id = tr.bank_statement_id + ) and tr.model_code = #{query.modelCode} @@ -1213,6 +1249,14 @@ where model_filter.project_id = #{query.projectId} and model_filter.bank_statement_id = final_result.bankStatementId and model_filter.model_code = #{query.modelCode} + and not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = model_filter.project_id + and ex.rule_code = model_filter.rule_code + and ex.exclusion_type = 'STATEMENT' + and ex.bank_statement_id = model_filter.bank_statement_id + ) ) @@ -1305,6 +1349,14 @@ from ccdi_bank_statement_tag_result tr where tr.project_id = #{query.projectId} and tr.bank_statement_id is not null + and not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = tr.project_id + and ex.rule_code = tr.rule_code + and ex.exclusion_type = 'STATEMENT' + and ex.bank_statement_id = tr.bank_statement_id + ) group by tr.bank_statement_id ) tag_result on tag_result.bank_statement_id = final_result.bankStatementId @@ -1534,6 +1586,7 @@ + select count(1) + from ccdi_bank_statement_tag_result tr + where tr.project_id = #{exclusion.projectId} + and tr.rule_code = #{exclusion.ruleCode} + + + and tr.bank_statement_id = #{exclusion.bankStatementId} + + + and ( + tr.object_key = #{exclusion.staffIdCard} + or exists ( + select 1 + from ccdi_bank_statement bs + where bs.bank_statement_id = tr.bank_statement_id + and bs.cret_no = #{exclusion.staffIdCard} + ) + or exists ( + select 1 + from ccdi_staff_fmy_relation relation + where relation.status = 1 + and relation.person_id = #{exclusion.staffIdCard} + and relation.relation_cert_no = tr.object_key + ) + or exists ( + select 1 + from ccdi_staff_fmy_relation relation + inner join ccdi_bank_statement bs + on bs.cret_no = relation.relation_cert_no + where relation.status = 1 + and relation.person_id = #{exclusion.staffIdCard} + and bs.bank_statement_id = tr.bank_statement_id + ) + ) + + + + + diff --git a/docs/design/2026-07-09-project-risk-exclusion-design.md b/docs/design/2026-07-09-project-risk-exclusion-design.md new file mode 100644 index 00000000..0e36e97e --- /dev/null +++ b/docs/design/2026-07-09-project-risk-exclusion-design.md @@ -0,0 +1,211 @@ +# 项目结果总览排除可疑改造设计 + +## 1. 背景 + +项目详情的结果总览当前直接消费银行流水打标结果与员工结果快照。用户在核查过程中如果确认某条预警不是可疑问题,只能在认知上忽略,系统没有持久化排除能力。刷新页面、重新进入项目或导出报告时,该预警仍会按有效命中展示。 + +仓库初始化 SQL 中已存在 `ccdi_project_risk_exclusion` 表,语义是“项目结果页排除可疑记录表”,字段能够承载项目、人员或流水、规则编码、排除类型与排除原因。本次改造使用该表完成排除记录持久化,不删除原始打标结果。 + +## 2. 目标 + +1. 用户可在项目分析详情的异常明细中对单条预警执行“排除可疑”。 +2. 排除对象精确到单条规则命中,不影响同一人员或同一流水的其他规则。 +3. 排除后当前页面立即刷新,风险人员、模型预警次数、命中人数、涉疑交易明细按有效命中重新展示。 +4. 浏览器刷新或重新进入项目后,已排除预警仍不作为有效预警展示。 +5. 本阶段不做“恢复预警”入口,保持最短闭环。 + +## 3. 非目标 + +1. 不删除 `ccdi_bank_statement_tag_result` 原始命中结果。 +2. 不使用 `localStorage`、`sessionStorage` 或 Cookie 保存排除状态。 +3. 不做批量排除、恢复排除、排除记录管理页。 +4. 不改变打标规则执行逻辑;重新打标后仍生成原始命中,展示查询阶段通过排除表过滤。 + +## 4. 用户操作链路 + +### 4.1 流水明细型预警 + +示例:某笔流水同时命中“大额转账交易”和“疑似敏感交易”。 + +1. 用户进入项目详情 > 结果总览。 +2. 点击风险人员或模型命中人员的“查看项目”。 +3. 在项目分析详情中进入“异常明细”。 +4. 在“流水异常明细”表格中,异常标签旁展示小号操作“排除可疑”。 +5. 用户点击某个标签的“排除可疑”,填写排除原因并确认。 +6. 系统只排除该笔流水上的该条规则标签。 + +排除键: + +```text +project_id + bank_statement_id + rule_code + exclusion_type = STATEMENT +``` + +效果: + +1. 被排除的标签不再显示。 +2. 同一笔流水的其他标签继续显示。 +3. 如果该笔流水没有剩余有效标签,则不再出现在异常流水明细中。 +4. 相关模型预警次数减少。 + +### 4.2 对象型 / 人员型预警 + +示例:某人命中“年流水交易额超限”,该预警是按人员聚合生成,不对应单笔流水。 + +1. 用户进入项目详情 > 结果总览。 +2. 点击该人员的“查看项目”。 +3. 在项目分析详情中进入“异常明细”。 +4. 在“对象异常明细”卡片右上角展示小号操作“排除可疑”,样式与“加入证据库”保持一致。 +5. 用户点击“排除可疑”,填写排除原因并确认。 +6. 系统只排除该人员命中的该条规则。 + +排除键: + +```text +project_id + staff_id_card + rule_code + exclusion_type = OBJECT +``` + +效果: + +1. 该对象型预警卡片不再显示。 +2. 该人员其他规则命中继续保留。 +3. 如果该人员没有剩余有效规则,则从风险人员列表中移除。 +4. 如果该人员仍有其他有效规则,则人员仍展示,但模型数、规则标签和风险等级按剩余有效命中重新计算。 + +## 5. 数据设计 + +使用既有表 `ccdi_project_risk_exclusion`。 + +关键字段: + +| 字段 | 用途 | +| --- | --- | +| `project_id` | 项目 ID | +| `staff_id_card` | 对象型预警对应人员证件号;外部人员也使用证件号 | +| `rule_code` | 被排除规则编码 | +| `exclusion_type` | `STATEMENT` 或 `OBJECT` | +| `bank_statement_id` | 流水型预警对应流水 ID | +| `exclude_reason` | 排除原因 | + +唯一约束: + +1. 流水型:`project_id + rule_code + exclusion_type + bank_statement_id` +2. 对象型:`project_id + staff_id_card + rule_code + exclusion_type` + +如果目标环境缺少该表,实施时补充增量 SQL,使用 `utf8mb4` 与 `utf8mb4_general_ci`。 + +## 6. 后端设计 + +### 6.1 新增接口 + +新增“排除可疑”接口: + +```text +POST /ccdi/project/overview/risk-exclusions +``` + +请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `projectId` | 是 | 项目 ID | +| `exclusionType` | 是 | `STATEMENT` 或 `OBJECT` | +| `ruleCode` | 是 | 规则编码 | +| `bankStatementId` | 流水型必填 | 流水 ID | +| `staffIdCard` | 对象型必填 | 人员证件号 | +| `excludeReason` | 是 | 排除原因 | + +校验规则: + +1. `exclusionType=STATEMENT` 时必须传 `bankStatementId`。 +2. `exclusionType=OBJECT` 时必须传 `staffIdCard`。 +3. `excludeReason` 不能为空,长度不超过 1000。 +4. 先校验用户对项目有读写权限;归档或只读项目不允许排除。 +5. 重复排除视为幂等成功,更新原因和更新时间。 + +### 6.2 查询过滤 + +所有结果总览相关查询应统一过滤排除表。 + +过滤规则: + +1. 读取 `ccdi_bank_statement_tag_result` 时左关联排除表。 +2. 流水型命中用 `project_id + rule_code + bank_statement_id + STATEMENT` 匹配。 +3. 对象型命中用 `project_id + rule_code + staff_id_card/object_key + OBJECT` 匹配。 +4. 匹配到排除记录的命中不进入后续聚合、列表、标签组装、导出。 + +重点影响范围: + +1. 风险人员列表。 +2. 风险模型卡片。 +3. 风险模型命中人员。 +4. 人员项目分析详情。 +5. 涉疑交易明细。 +6. 一键 PDF 报告与 Excel 导出。 +7. 外部人员预警及外部人员详情。 + +### 6.3 员工结果快照 + +当前风险人员列表读取 `ccdi_project_overview_employee_result` 快照。为了让“排除可疑”后统计即时变化,实施时应在排除成功后触发当前项目员工结果快照重算,或将列表查询切回有效命中实时聚合。 + +本次推荐: + +1. 排除接口写入排除表后,同步触发当前项目结果总览员工快照重算。 +2. 重算逻辑只使用未排除命中。 +3. 页面刷新后读取更新后的快照。 + +理由:保留现有列表分页性能与页面结构,改动集中,不引入双口径。 + +## 7. 前端设计 + +### 7.1 操作入口 + +流水异常明细: + +1. 异常标签展示为标签 + 小号文字按钮。 +2. 按钮文案为“排除可疑”。 +3. 按钮只作用于当前标签,不作用于整行。 + +对象异常明细: + +1. 卡片右上角增加小号按钮“排除可疑”。 +2. 样式仿照“加入证据库”,保持轻量。 +3. 不新增大按钮、不改变卡片主视觉。 + +### 7.2 确认弹窗 + +使用 Element UI 弹窗或对话框,要求用户填写排除原因。 + +确认文案需要明确影响范围: + +1. 流水型:仅排除当前流水的当前规则标签。 +2. 对象型:仅排除当前人员的当前规则预警。 + +### 7.3 刷新策略 + +排除成功后,前端通知父组件刷新: + +1. 重新加载项目分析详情。 +2. 重新加载风险人员列表。 +3. 重新加载风险模型卡片与命中人员。 +4. 重新加载涉疑交易明细。 + +不做局部假刷新,避免页面统计与后端状态不一致。 + +## 8. 风险与约束 + +1. 如果只过滤详情、不刷新快照,风险人员列表和模型统计会不一致;必须统一刷新统计口径。 +2. 对象型预警没有 `bank_statement_id`,必须用人员证件号和规则编码定位。 +3. 外部人员对象型预警同样需要按证件号处理,不能只适配员工。 +4. 排除记录不删除原始命中,因此重新打标不会覆盖排除结果。 + +## 9. 验证场景 + +1. 流水一条多标签,排除其中一个标签后另一个标签仍显示。 +2. 排除流水标签后模型预警次数减少。 +3. 排除对象型“年流水交易额超限”后对象卡片消失。 +4. 人员仅剩一条对象型预警时,排除后人员不再出现在风险人员列表。 +5. 人员有多条预警时,排除一条后人员仍保留,标签和统计减少。 +6. 刷新浏览器后排除结果仍生效。 +7. 导出报告不包含已排除预警。 +8. 归档或只读项目不允许排除。 + diff --git a/docs/plans/backend/2026-07-09-project-risk-exclusion-backend-implementation.md b/docs/plans/backend/2026-07-09-project-risk-exclusion-backend-implementation.md new file mode 100644 index 00000000..a3b760ed --- /dev/null +++ b/docs/plans/backend/2026-07-09-project-risk-exclusion-backend-implementation.md @@ -0,0 +1,264 @@ +# 项目结果总览排除可疑后端实施计划 + +## 1. 目标 + +后端提供“排除可疑”持久化接口,并在结果总览所有预警查询、统计、导出链路中统一过滤已排除命中。排除粒度为单条规则命中,不删除原始打标结果。 + +## 2. 涉及范围 + +模块: + +1. `ccdi-project` +2. `ruoyi-admin` 装配依赖无需调整 +3. `sql/migration/` + +重点文件: + +1. `CcdiProjectOverviewController` +2. `ICcdiProjectOverviewService` +3. `CcdiProjectOverviewServiceImpl` +4. `CcdiProjectOverviewMapper` +5. `CcdiBankTagResultMapper` +6. 新增排除记录实体、DTO、Mapper + +## 3. 数据库实施 + +### 3.1 增量脚本 + +新增 SQL: + +```text +sql/migration/2026-07-09-create-project-risk-exclusion.sql +``` + +内容使用 `CREATE TABLE IF NOT EXISTS ccdi_project_risk_exclusion`,字段与 `sql/ccdi_prod_init.sql` 保持一致,并显式声明: + +```sql +DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci +``` + +### 3.2 表用途 + +`ccdi_project_risk_exclusion` 保存排除记录: + +1. `STATEMENT`:单笔流水上的单个规则标签。 +2. `OBJECT`:某个对象或人员上的单个规则预警。 + +## 4. 后端对象 + +### 4.1 新增 Entity + +新增: + +```text +ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectRiskExclusion.java +``` + +字段对应表结构,实体类使用 Lombok `@Data`,不继承 `BaseEntity`。 + +### 4.2 新增 DTO + +新增: + +```text +ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskExclusionSaveDTO.java +``` + +字段: + +1. `projectId` +2. `staffIdCard` +3. `ruleCode` +4. `exclusionType` +5. `bankStatementId` +6. `excludeReason` + +校验: + +1. `projectId` 必填。 +2. `ruleCode` 必填。 +3. `exclusionType` 必填且只允许 `STATEMENT`、`OBJECT`。 +4. `excludeReason` 必填且最大 1000 字符。 +5. `STATEMENT` 要求 `bankStatementId`。 +6. `OBJECT` 要求 `staffIdCard`。 + +### 4.3 新增 Mapper + +新增: + +```text +ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectRiskExclusionMapper.java +ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectRiskExclusionMapper.xml +``` + +方法: + +1. `upsertExclusion` +2. `selectByProjectId` +3. `selectStatementExclusions` +4. `selectObjectExclusions` + +`upsertExclusion` 使用唯一键实现幂等写入;重复排除时更新 `exclude_reason/update_by/update_time`。 + +## 5. 接口实施 + +在 `CcdiProjectOverviewController` 新增: + +```text +POST /ccdi/project/overview/risk-exclusions +``` + +返回: + +```java +AjaxResult.success("排除成功") +``` + +权限: + +1. `@PreAuthorize("@ss.hasPermi('ccdi:project:query')")` +2. 使用 `projectAccessService.assertCanRead(projectId)` 校验项目访问。 +3. 使用项目状态或访问服务校验当前项目可操作;归档或只读项目返回错误。 + +## 6. Service 实施 + +在 `ICcdiProjectOverviewService` 增加: + +```java +void excludeRisk(CcdiProjectRiskExclusionSaveDTO dto); +``` + +`CcdiProjectOverviewServiceImpl` 实现流程: + +1. 校验 DTO。 +2. 校验规则是否存在于当前项目有效命中中,避免写入无效排除记录。 +3. 写入 `ccdi_project_risk_exclusion`。 +4. 触发当前项目结果总览员工快照重算。 +5. 返回成功。 + +## 7. 查询过滤实施 + +### 7.1 统一过滤 SQL + +在 `CcdiProjectOverviewMapper.xml` 中新增复用 SQL 片段: + +```xml +not exists ( + select 1 + from ccdi_project_risk_exclusion ex + where ex.project_id = tr.project_id + and ex.rule_code = tr.rule_code + and ( + (ex.exclusion_type = 'STATEMENT' and ex.bank_statement_id = tr.bank_statement_id) + or + (ex.exclusion_type = 'OBJECT' and ex.staff_id_card = resolved_staff_id_card) + ) +) +``` + +实际实现时根据不同查询上下文替换 `resolved_staff_id_card`。 + +### 7.2 员工风险基础 SQL + +修改 `resolvedEmployeeRiskBaseSql`: + +1. 先解析 `staff_id_card`。 +2. 对 `STATEMENT` 和 `OBJECT` 分别过滤。 +3. 被排除命中不进入员工风险聚合。 + +### 7.3 风险模型卡片 + +修改模型统计查询: + +1. `warning_count` 按未排除命中统计。 +2. `people_count` 按未排除命中涉及人员去重。 + +### 7.4 风险模型命中人员 + +修改命中人员查询: + +1. `hitTagList` 不包含已排除规则。 +2. `modelNames` 按剩余命中规则组装。 +3. 过滤后无有效规则的人员不返回。 + +### 7.5 人员项目分析详情 + +修改详情组装: + +1. `BANK_STATEMENT` 记录的 `hitTags` 排除已排除标签。 +2. 如果一条流水没有剩余 `hitTags`,不进入流水异常明细。 +3. `OBJECT` 记录排除已排除对象规则。 + +### 7.6 涉疑交易明细与导出 + +修改涉疑交易查询: + +1. 已排除流水标签不进入 `hitTags`。 +2. 过滤后无有效标签的流水不作为可疑流水展示。 +3. Excel 导出和 PDF 报告复用同一过滤口径。 + +### 7.7 外部人员预警 + +外部人员使用证件号作为对象键: + +1. `OBJECT` 类型按 `staff_id_card = cert_no` 过滤。 +2. `STATEMENT` 类型按 `bank_statement_id` 过滤。 +3. 外部人员列表、模型卡片、详情、报告口径一致。 + +## 8. 快照重算 + +如果当前已有员工结果快照重算逻辑,排除成功后复用该逻辑。 + +若没有可复用入口,新增内部方法: + +```java +refreshOverviewEmployeeResult(Long projectId) +``` + +要求: + +1. 使用过滤排除后的有效命中重算。 +2. 删除或更新当前项目 `ccdi_project_overview_employee_result`。 +3. 确保列表、模型统计和详情口径一致。 + +## 9. 测试计划 + +### 9.1 后端编译 + +```bash +mvn -pl ccdi-project -am compile -DskipTests +``` + +### 9.2 单接口验证 + +验证接口: + +```text +POST /ccdi/project/overview/risk-exclusions +``` + +场景: + +1. 缺少原因返回错误。 +2. `STATEMENT` 缺少 `bankStatementId` 返回错误。 +3. `OBJECT` 缺少 `staffIdCard` 返回错误。 +4. 重复排除幂等成功。 +5. 归档项目或只读项目不允许排除。 + +### 9.3 数据口径验证 + +场景: + +1. 一笔流水多个标签,排除一个后另一个仍存在。 +2. 人员多个对象型预警,排除一个后人员仍存在。 +3. 人员唯一预警被排除后,风险人员列表不再展示该人员。 +4. 模型预警次数和命中人数减少。 +5. PDF/Excel 不输出已排除预警。 + +## 10. 风险控制 + +1. 排除表只影响展示和统计,不影响原始命中结果。 +2. 所有新增 SQL 使用 `utf8mb4_general_ci`。 +3. `rule_code` 保持全大写。 +4. 不引入恢复接口,避免扩大范围。 + diff --git a/docs/plans/frontend/2026-07-09-project-risk-exclusion-frontend-implementation.md b/docs/plans/frontend/2026-07-09-project-risk-exclusion-frontend-implementation.md new file mode 100644 index 00000000..739d43d9 --- /dev/null +++ b/docs/plans/frontend/2026-07-09-project-risk-exclusion-frontend-implementation.md @@ -0,0 +1,293 @@ +# 项目结果总览排除可疑前端实施计划 + +## 1. 目标 + +在项目分析详情的异常明细中提供轻量“排除可疑”操作。用户确认排除后,页面重新请求后端数据,展示过滤后的风险人员、模型预警次数、命中人数与异常明细。 + +本阶段不做恢复入口,不做排除记录列表。 + +## 2. 涉及范围 + +前端模块: + +```text +ruoyi-ui +``` + +重点文件: + +1. `ruoyi-ui/src/api/ccdi/projectOverview.js` +2. `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue` +3. `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue` +4. `ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue` +5. `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue` +6. `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue` +7. `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue` +8. `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` + +## 3. API 封装 + +在 `projectOverview.js` 新增: + +```js +export function excludeOverviewRisk(data) { + return request({ + url: '/ccdi/project/overview/risk-exclusions', + method: 'post', + data + }) +} +``` + +请求字段: + +```js +{ + projectId, + exclusionType, + ruleCode, + bankStatementId, + staffIdCard, + excludeReason +} +``` + +## 4. UI 设计 + +### 4.1 流水异常明细 + +位置: + +```text +ProjectAnalysisAbnormalTab.vue > BANK_STATEMENT 表格 > 异常标签列 +``` + +展示方式: + +1. 每个异常标签保持 `el-tag`。 +2. 标签右侧增加小号文字按钮“排除可疑”。 +3. 按钮与标签在同一行内,不增加大操作列。 + +交互: + +1. 点击“排除可疑”。 +2. 打开确认弹窗。 +3. 用户填写排除原因。 +4. 确认后调用后端接口。 + +提交参数: + +```js +{ + projectId, + exclusionType: 'STATEMENT', + ruleCode: tag.ruleCode, + bankStatementId: row.bankStatementId, + staffIdCard: resolvePersonIdCard(), + excludeReason +} +``` + +其中 `staffIdCard` 仅作为上下文传递,后端流水型以 `bankStatementId` 为主。 + +### 4.2 对象异常明细 + +位置: + +```text +ProjectAnalysisAbnormalTab.vue > OBJECT 卡片右上角 +``` + +展示方式: + +1. 卡片右上角增加小号按钮“排除可疑”。 +2. 样式仿照现有“加入证据库”按钮。 +3. 不新增恢复按钮。 + +提交参数: + +```js +{ + projectId, + exclusionType: 'OBJECT', + ruleCode: item.ruleCode || item.modelCode, + staffIdCard: resolvePersonIdCard(), + excludeReason +} +``` + +实施时需要确保对象异常记录保留真实 `ruleCode`。如果当前对象卡片只带 `modelCode`,前后端需补齐 `ruleCode` 字段,不能用 `modelCode` 替代规则编码。 + +## 5. 确认弹窗 + +使用 Element UI 对话框或 `$prompt`。 + +推荐文案: + +流水型: + +```text +确认将当前流水的“{规则名称}”标记为排除可疑吗? +该操作只影响当前流水上的这一个规则标签。 +``` + +对象型: + +```text +确认将“{人员姓名}”的“{规则名称}”标记为排除可疑吗? +该操作只影响当前人员的这一个规则预警。 +``` + +输入框: + +```text +请输入排除原因 +``` + +校验: + +1. 原因不能为空。 +2. 原因长度不超过 1000。 + +## 6. 刷新策略 + +排除成功后不做前端本地假删除,统一通知父组件刷新。 + +事件链路: + +1. `ProjectAnalysisAbnormalTab` 调用接口成功。 +2. 向上 emit `risk-excluded`。 +3. `ProjectAnalysisDialog` 接收后重新加载当前人员详情,并继续向上 emit。 +4. `PreliminaryCheck` 接收后重新加载结果总览数据。 +5. 子组件因 props 更新重新加载风险人员、模型卡片、命中人员与涉疑交易。 + +外部人员详情: + +1. `ExternalPersonDetailDialog` 接收 `risk-excluded` 后重新加载外部人员流水。 +2. 同时通知 `PreliminaryCheck` 刷新总览数据。 + +需要刷新的数据: + +1. 风险人员列表。 +2. 外部人员预警列表。 +3. 风险模型卡片。 +4. 风险模型命中人员。 +5. 涉疑交易明细。 +6. 当前弹窗异常明细。 + +## 7. 页面状态 + +### 7.1 操作中 + +点击确认后按钮进入 loading 或禁用状态,避免重复提交。 + +### 7.2 成功 + +提示: + +```text +排除成功 +``` + +随后刷新页面数据。 + +### 7.3 失败 + +提示后端错误信息: + +```text +排除失败,请稍后重试 +``` + +不修改当前页面数据。 + +### 7.4 只读项目 + +如果 `canOperate=false`,不展示“排除可疑”按钮,或展示禁用态并提示当前项目仅可查看。 + +## 8. 数据要求 + +前端需要从后端获取或透传以下字段: + +流水标签: + +1. `ruleCode` +2. `ruleName` +3. `bankStatementId` + +对象卡片: + +1. `ruleCode` +2. `ruleName` +3. `modelCode` +4. `modelName` +5. `reasonDetail` + +人员上下文: + +1. `projectId` +2. `idNo` 或 `staffIdCard` +3. `name` 或 `staffName` + +如果对象异常卡片缺少 `ruleCode`,应先补齐数据映射,再展示排除按钮。 + +## 9. 测试计划 + +### 9.1 Node 环境 + +前端命令执行前按仓库规则: + +```bash +cd ruoyi-ui +nvm use +node -v +npm -v +where node +where npm +``` + +如果 Node 14.21.3 缺少 `npm.cmd`,切换: + +```bash +nvm use 22.22.3 +``` + +并在实施记录中说明实际版本。 + +### 9.2 构建验证 + +```bash +npm run build:prod +``` + +### 9.3 浏览器验证 + +完成页面开发后,使用 `browser-use` 打开真实业务页面验证,不打开 prototype。 + +验证路径: + +1. 登录系统。 +2. 进入项目详情。 +3. 打开结果总览。 +4. 打开某个风险人员项目分析详情。 +5. 对流水标签执行“排除可疑”。 +6. 确认弹窗填写原因。 +7. 验证异常明细、模型统计、风险人员数据刷新。 +8. 刷新浏览器,验证排除仍生效。 +9. 对对象型“年流水交易额超限”执行同样验证。 + +### 9.4 关键用例 + +1. 单笔流水多标签,只排除其中一个标签。 +2. 对象型单预警人员,排除后人员从风险列表消失。 +3. 对象型多预警人员,排除后人员保留但标签和次数减少。 +4. 只读项目不显示或禁用按钮。 +5. 排除原因为空时不能提交。 + +## 10. 不做事项 + +1. 不做恢复入口。 +2. 不做排除记录管理页。 +3. 不使用浏览器本地存储。 +4. 不新增批量排除。 + diff --git a/docs/reports/implementation/2026-07-09-project-risk-exclusion-implementation.md b/docs/reports/implementation/2026-07-09-project-risk-exclusion-implementation.md new file mode 100644 index 00000000..aaed944d --- /dev/null +++ b/docs/reports/implementation/2026-07-09-project-risk-exclusion-implementation.md @@ -0,0 +1,93 @@ +# 项目结果总览排除可疑实施记录 + +## 基本信息 + +- 实施日期:2026-07-09 +- 功能范围:项目详情 > 结果总览 > 项目分析异常明细中的单条可疑预警排除 +- 需求口径:支持流水明细级预警和人员对象级预警排除;本期不做恢复入口;排除后刷新当前详情、风险人员列表、模型统计、涉疑交易明细;浏览器刷新后仍按排除记录过滤。 + +## 修改内容 + +### 后端 + +- 新增排除记录表 `ccdi_project_risk_exclusion` 的增量 SQL。 +- 新增排除记录实体、DTO、Mapper 与 XML。 +- 在 `CcdiProjectOverviewController` 新增 `POST /ccdi/project/overview/risk-exclusions`。 +- 在结果总览服务中新增排除逻辑: + - 校验项目可操作状态。 + - 校验排除目标必须存在于当前项目命中结果。 + - 按 `STATEMENT` 或 `OBJECT` 维度写入排除记录。 + - 写入后刷新项目员工结果汇总。 +- 在结果总览、风险人员、模型统计、人员详情、涉疑交易明细等 SQL 中统一过滤排除记录。 +- 修复对象型排除校验 SQL 中子查询 `JOIN ON` 引用外层字段导致的 MySQL 语法错误。 + +### 前端 + +- 新增 `excludeOverviewRisk` API。 +- 在项目分析异常明细中为流水标签和异常对象摘要增加小号 `排除可疑` 按钮。 +- 使用 Element UI 弹窗录入排除原因并确认。 +- 排除成功后刷新: + - 当前项目分析详情弹窗。 + - 风险人员列表。 + - 风险模型卡片和命中人员。 + - 涉疑交易明细,并清空流水详情缓存后重拉。 +- 外部人员详情弹窗同步支持相同排除能力。 +- 对象型确认文案改为优先显示规则摘要,避免把人员名当作规则名展示。 +- 修复排除成功后的用户体验: + - 父级结果总览改为静默刷新,不再触发整页 loading,也不重置已打开的项目分析弹窗。 + - 刷新结果回写到当前项目分析人员,弹窗侧栏异常标签与总览统计保持一致。 + - 项目分析详情未加载完成前不再回退展示 mock 异常明细,避免打开 `查看详情` 时闪过旧数据。 + +## 验证情况 + +### 编译与构建 + +- `mvn -pl ccdi-project -am compile -DskipTests`:通过。 +- `mvn -pl ruoyi-admin -am package -DskipTests`:通过。 +- `npm run build:prod`:通过。 + +前端按本机实际可用环境执行: + +- `.nvmrc` 指向 Node `14.21.3`。 +- 本机 Node 14 环境缺少完整 npm,按项目约定切换到已验证可用的 Node `22.22.3`。 +- PowerShell 中 `C:\Users\20696\AppData\Local\nvm-nodejs\npm.cmd` 链接不可用,本次构建改用已验证的 `C:\Users\20696\AppData\Roaming\nvm\v22.22.3` 下 Node/npm 执行。 +- 构建存在既有体积告警;未跟踪的 `ruoyi-ui/public/0uS5znL.docx` 被打入构建并触发体积提示,非本次功能改动产生。 + +### 接口验证 + +- 后端真实 Java 服务启动在 `http://127.0.0.1:62318`,`/swagger-ui.html` 返回 200。 +- 通过 `POST /login/test` 获取测试 token。 +- 对项目 `42`、人员 `330101198802020033`、对象型规则 `ANNUAL_TURNOVER` 调用排除接口: + - 排除前对象规则包含 `ANNUAL_TURNOVER,CUMULATIVE_INCOME`。 + - `POST /ccdi/project/overview/risk-exclusions` 返回 `code=200`。 + - 排除后人员详情对象规则仅剩 `CUMULATIVE_INCOME`。 + +### 浏览器验证 + +- 使用真实前端开发服务 `http://127.0.0.1:8088`,代理真实后端 `http://127.0.0.1:62318`。 +- 进入项目 `42` 的结果总览。 +- 点击风险人员的 `查看详情`,进入项目分析弹窗。 +- 在异常对象摘要点击 `排除可疑` 并确认。 +- 页面立即刷新: + - 当前 URL 保持在 `/ccdiProject/detail/42?tab=overview`,未回到项目列表或结果总览首页。 + - 项目分析弹窗保持打开,未被父页面刷新关闭。 + - 风险人员疑似违规数从 `5` 变为 `4`。 + - 模型统计从 `大额交易 4 次` 变为 `大额交易 3 次`。 + - 风险人员列表风险等级从 `高风险` 变为 `中风险`。 + - 命中人员异常标签去掉已排除对象规则。 + - 项目分析弹窗侧栏与异常对象摘要同步去掉 `年流水交易额超限`。 +- 刷新浏览器后仍保持过滤结果,说明排除记录持久化生效。 +- 打开 `查看详情` 的首帧只显示空/加载态,不再闪过 mock 或上一次人员的异常明细。 +- 浏览器 Network 面板中本轮验收 22 个 XHR/fetch 请求均为 200,未出现后端接口异常。 + +### 数据清理 + +- 验证后删除本轮写入的 `ANNUAL_TURNOVER`、`CUMULATIVE_INCOME` 对象型排除记录。 +- 通过真实后端 `POST /ccdi/project/tags/rebuild` 重算项目 `42` 标签与结果汇总。 +- 清理后确认本轮对象型排除记录剩余 `0`,项目 `42` 汇总恢复为 `模型二测试员工 / rule_count=5 / model_count=2 / hit_count=5`。 + +## 影响范围 + +- 新增排除表为独立业务表,不修改原始流水、员工、模型命中明细。 +- 查询层通过 `NOT EXISTS` 统一过滤已排除预警。 +- 本期不提供恢复入口;需要恢复时需后续单独设计恢复接口和入口。 diff --git a/ruoyi-ui/src/api/ccdi/projectOverview.js b/ruoyi-ui/src/api/ccdi/projectOverview.js index e1b8e3eb..53a83367 100644 --- a/ruoyi-ui/src/api/ccdi/projectOverview.js +++ b/ruoyi-ui/src/api/ccdi/projectOverview.js @@ -8,6 +8,14 @@ export function getOverviewDashboard(projectId) { }) } +export function excludeOverviewRisk(data) { + return request({ + url: '/ccdi/project/overview/risk-exclusions', + method: 'post', + data + }) +} + export function getOverviewRiskPeople(params) { return request({ url: '/ccdi/project/overview/risk-people', diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue index 86d79002..2600a515 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue @@ -51,7 +51,9 @@ :detail-data="abnormalDetailData" :person="normalizedPerson" :project-id="projectId" + :can-operate="canOperate" @evidence-confirm="$emit('evidence-confirm', $event)" + @risk-excluded="handleRiskExcluded" /> @@ -102,6 +104,10 @@ export default { type: String, default: "", }, + canOperate: { + type: Boolean, + default: true, + }, }, data() { return { @@ -314,6 +320,7 @@ export default { const statementReason = this.buildStatementReasonDetail(ruleName, matchedRows); return { modelCode: safeTag.modelCode || `EXTERNAL_MODEL_${index}`, + ruleCode: safeTag.ruleCode || "", title: ruleName, subtitle: modelName, riskTags: [modelName, this.formatRiskLevel(safeTag.riskLevel)].filter(Boolean), @@ -417,6 +424,10 @@ export default { } return ""; }, + async handleRiskExcluded(payload) { + await this.loadStatementRows(); + this.$emit("risk-excluded", payload); + }, closeDialog() { this.visibleProxy = false; }, diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue index 3c0fb17f..16b65b46 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue @@ -32,6 +32,7 @@ @@ -61,13 +64,16 @@ :can-operate="canOperate" @close="handleProjectAnalysisDialogClose" @evidence-confirm="$emit('evidence-confirm', $event)" + @risk-excluded="handleRiskExcluded" /> @@ -266,6 +272,61 @@ export default { this.currentExternalPerson = row || null; this.externalDetailVisible = true; }, + async handleRiskExcluded() { + await this.loadOverviewData({ silent: true }); + this.syncCurrentProjectAnalysisPerson(); + this.$nextTick(() => { + const riskModelSection = this.$refs.riskModelSection; + if (riskModelSection) { + if (riskModelSection.loadExternalCards) { + riskModelSection.loadExternalCards(); + } + if (riskModelSection.fetchPeopleList) { + riskModelSection.fetchPeopleList({ syncCardLoading: true }); + } + } + const riskDetailSection = this.$refs.riskDetailSection; + if (riskDetailSection && riskDetailSection.refreshAfterRiskExcluded) { + riskDetailSection.refreshAfterRiskExcluded(); + } + }); + }, + syncCurrentProjectAnalysisPerson() { + if (!this.projectAnalysisDialogVisible || !this.currentProjectAnalysisPerson) { + return; + } + const currentStaffIdCard = this.currentProjectAnalysisPerson.idNo + || this.currentProjectAnalysisPerson.staffIdCard + || ""; + if (!currentStaffIdCard) { + return; + } + const latestPerson = this.findOverviewPersonByIdCard(currentStaffIdCard); + if (!latestPerson) { + return; + } + this.currentProjectAnalysisPerson = { + ...this.currentProjectAnalysisPerson, + ...latestPerson, + }; + this.projectAnalysisModelSummary = this.buildProjectAnalysisModelSummary( + this.projectAnalysisSource, + this.currentProjectAnalysisPerson + ); + }, + findOverviewPersonByIdCard(staffIdCard) { + const employeeRows = Array.isArray(this.realData && this.realData.riskPeople && this.realData.riskPeople.rows) + ? this.realData.riskPeople.rows + : []; + const externalRows = this.riskPeopleScopeSummary + && this.riskPeopleScopeSummary.external + && Array.isArray(this.riskPeopleScopeSummary.external.rows) + ? this.riskPeopleScopeSummary.external.rows + : []; + return [...employeeRows, ...externalRows].find((item) => ( + item && (item.idNo === staffIdCard || item.staffIdCard === staffIdCard) + )); + }, openProjectAnalysisDialog(source, person) { this.projectAnalysisSource = source || "riskPeople"; this.currentProjectAnalysisPerson = person || null; @@ -324,7 +385,8 @@ export default { `初核结果报告_${this.projectId}_${new Date().getTime()}.pdf` ); }, - async loadOverviewData() { + async loadOverviewData(options = {}) { + const silent = Boolean(options && options.silent); if (!this.projectId) { this.realData = this.stateDataMap.empty; this.pageState = "empty"; @@ -334,17 +396,19 @@ export default { return; } - this.pageState = "loading"; - this.selectedModelCodes = []; - this.riskPeopleScopeSummary = { - activeTab: "employee", - external: { - total: 0, - rows: [], - }, - }; - this.resetProjectAnalysisDialog(); - this.resetExternalDetailDialog(); + if (!silent) { + this.pageState = "loading"; + this.selectedModelCodes = []; + this.riskPeopleScopeSummary = { + activeTab: "employee", + external: { + total: 0, + rows: [], + }, + }; + this.resetProjectAnalysisDialog(); + this.resetExternalDetailDialog(); + } try { const [ dashboardRes, @@ -399,11 +463,13 @@ export default { this.pageState = hasOverviewData ? "loaded" : "empty"; } catch (error) { - this.realData = this.stateDataMap.empty; - this.pageState = "empty"; - this.selectedModelCodes = []; - this.resetProjectAnalysisDialog(); - this.resetExternalDetailDialog(); + if (!silent) { + this.realData = this.stateDataMap.empty; + this.pageState = "empty"; + this.selectedModelCodes = []; + this.resetProjectAnalysisDialog(); + this.resetExternalDetailDialog(); + } console.error("加载结果总览失败", error); } }, diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue index 26ecd188..a7bfc22a 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue @@ -54,14 +54,26 @@ @@ -93,15 +105,28 @@
{{ item.title || "-" }}
{{ item.subtitle || "-" }}
- - 加入证据库 - +
+ + 排除可疑 + + + 加入证据库 + +
import { saveAs } from "file-saver"; +import { excludeOverviewRisk } from "@/api/ccdi/projectOverview"; import { buildModelEvidenceFingerprint, MODEL_EVIDENCE_FINGERPRINT_RULE } from "@/utils/ccdiEvidence"; export default { @@ -156,11 +182,16 @@ export default { type: [String, Number], default: null, }, + canOperate: { + type: Boolean, + default: true, + }, }, data() { return { statementPageSize: 5, statementPageMap: {}, + excludingKey: "", }; }, watch: { @@ -205,6 +236,95 @@ export default { const groupKey = this.resolveGroupKey(group); this.$set(this.statementPageMap, groupKey, page); }, + buildStatementExcludeKey(row, tag) { + return `STATEMENT:${row && row.bankStatementId}:${tag && tag.ruleCode}`; + }, + buildObjectExcludeKey(item) { + return `OBJECT:${this.resolvePersonIdCard()}:${item && item.ruleCode}`; + }, + isExcluding(key) { + return Boolean(key && this.excludingKey === key); + }, + async handleExcludeStatementRisk(row, tag) { + if (!this.canOperate) { + this.$message.warning("当前项目仅可查看,不能排除可疑"); + return; + } + if (!this.projectId || !row || !row.bankStatementId || !tag || !tag.ruleCode) { + this.$message.warning("缺少流水或规则信息,暂不能排除可疑"); + return; + } + const ruleName = tag.ruleName || "当前规则"; + await this.submitRiskExclusion({ + key: this.buildStatementExcludeKey(row, tag), + message: `确认将当前流水的“${ruleName}”标记为排除可疑吗?该操作只影响当前流水上的这一个规则标签。`, + payload: { + projectId: this.projectId, + exclusionType: "STATEMENT", + ruleCode: tag.ruleCode, + bankStatementId: row.bankStatementId, + staffIdCard: this.resolvePersonIdCard(), + }, + }); + }, + async handleExcludeObjectRisk(item) { + if (!this.canOperate) { + this.$message.warning("当前项目仅可查看,不能排除可疑"); + return; + } + const staffIdCard = this.resolvePersonIdCard(); + if (!this.projectId || !staffIdCard || !item || !item.ruleCode) { + this.$message.warning("缺少人员或规则信息,暂不能排除可疑"); + return; + } + const ruleName = item.summary || item.title || "当前规则"; + await this.submitRiskExclusion({ + key: this.buildObjectExcludeKey(item), + message: `确认将“${this.resolvePersonName()}”的“${ruleName}”标记为排除可疑吗?该操作只影响当前人员的这一个规则预警。`, + payload: { + projectId: this.projectId, + exclusionType: "OBJECT", + ruleCode: item.ruleCode, + staffIdCard, + }, + }); + }, + async submitRiskExclusion({ key, message, payload }) { + if (this.excludingKey) { + return; + } + try { + const { value } = await this.$prompt(message, "排除可疑", { + confirmButtonText: "确认排除", + cancelButtonText: "取消", + inputType: "textarea", + inputPlaceholder: "请输入排除原因", + inputValidator: (value) => { + if (!value || !value.trim()) { + return "排除原因不能为空"; + } + if (value.trim().length > 1000) { + return "排除原因不能超过1000个字符"; + } + return true; + }, + }); + this.excludingKey = key; + await excludeOverviewRisk({ + ...payload, + excludeReason: value.trim(), + }); + this.$message.success("排除成功"); + this.$emit("risk-excluded", payload); + } catch (error) { + if (error !== "cancel" && error !== "close") { + const message = error && error.msg ? error.msg : "排除失败,请稍后重试"; + this.$message.error(message); + } + } finally { + this.excludingKey = ""; + } + }, shouldShowExportButton(group) { return group && group.groupType === "BANK_STATEMENT"; }, @@ -403,6 +523,17 @@ export default { gap: 10px; } +.tag-with-action { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.exclude-risk-btn { + padding: 0; + font-size: 12px; +} + .object-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); @@ -424,6 +555,12 @@ export default { margin-bottom: 8px; } +.object-card__actions { + display: inline-flex; + flex: 0 0 auto; + gap: 6px; +} + .object-card__title { font-size: 15px; font-weight: 600; diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue index c12bb5be..3ce2faf7 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue @@ -60,7 +60,9 @@ :detail-data="dialogData.abnormalDetail" :person="person" :project-id="projectId" + :can-operate="canOperate" @evidence-confirm="$emit('evidence-confirm', $event)" + @risk-excluded="handleRiskExcluded" /> @@ -222,7 +224,7 @@ export default { ...(this.modelSummary || {}), }, }, - abnormalDetail: (this.detailData && this.detailData.abnormalDetail) || mockData.abnormalDetail, + abnormalDetail: (this.detailData && this.detailData.abnormalDetail) || { groups: [] }, }; }, visibleProxy: { @@ -301,6 +303,10 @@ export default { handleRetryDetail() { this.fetchDetailData(); }, + async handleRiskExcluded(payload) { + await this.fetchDetailData(); + this.$emit("risk-excluded", payload); + }, async fetchAssetDetailData() { if (this.assetLoaded || this.assetLoading) { return; diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue index a6796c34..dff1a414 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue @@ -579,6 +579,21 @@ export default { this.suspiciousPageNum = 1; await this.loadSuspiciousTransactions(); }, + async refreshAfterRiskExcluded() { + this.statementDetailCache = {}; + await this.loadSuspiciousTransactions(); + if (this.detailVisible && this.detailData && this.detailData.bankStatementId) { + try { + this.detailLoading = true; + this.detailData = await this.fetchStatementDetail(this.detailData.bankStatementId, false); + } catch (error) { + this.$message.error("刷新流水详情失败"); + console.error("刷新流水详情失败", error); + } finally { + this.detailLoading = false; + } + } + }, async handlePageChange(pageInfo) { if (typeof pageInfo === "number") { this.suspiciousPageNum = pageInfo; diff --git a/sql/migration/2026-07-09-create-project-risk-exclusion.sql b/sql/migration/2026-07-09-create-project-risk-exclusion.sql new file mode 100644 index 00000000..f90f249b --- /dev/null +++ b/sql/migration/2026-07-09-create-project-risk-exclusion.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS `ccdi_project_risk_exclusion` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `project_id` BIGINT NOT NULL COMMENT '项目ID', + `staff_id_card` VARCHAR(18) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '人员证件号', + `rule_code` VARCHAR(64) COLLATE utf8mb4_general_ci NOT NULL COMMENT '规则编码', + `exclusion_type` VARCHAR(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT '排除类型:STATEMENT/OBJECT', + `bank_statement_id` BIGINT DEFAULT NULL COMMENT '流水ID,仅流水型排除使用', + `exclude_reason` VARCHAR(1000) COLLATE utf8mb4_general_ci NOT NULL COMMENT '排除原因', + `create_by` VARCHAR(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ccdi_project_risk_exclusion_statement` (`project_id`,`rule_code`,`exclusion_type`,`bank_statement_id`), + UNIQUE KEY `uk_ccdi_project_risk_exclusion_object` (`project_id`,`staff_id_card`,`rule_code`,`exclusion_type`), + KEY `idx_ccdi_project_risk_exclusion_project` (`project_id`,`exclusion_type`), + KEY `idx_ccdi_project_risk_exclusion_staff` (`staff_id_card`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='项目结果页排除可疑记录表';