完善外部人员预警与项目分析上线内容

This commit is contained in:
wjj
2026-06-30 10:23:55 +08:00
parent 4e90e22ee2
commit 5e4bfca05b
77 changed files with 5788 additions and 333 deletions

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;
@@ -67,6 +73,28 @@ public class CcdiProjectOverviewController extends BaseController {
return AjaxResult.success(overview);
}
/**
* 查询外部人员预警
*/
@GetMapping("/external-persons")
@Operation(summary = "查询外部人员预警")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExternalPersons(CcdiProjectExternalPersonQueryDTO queryDTO) {
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) {
CcdiProjectExternalRiskSummaryVO summary = overviewService.getExternalRiskSummary(projectId);
return AjaxResult.success(summary);
}
/**
* 查询中高风险人员TOP10
*/
@@ -100,6 +128,28 @@ public class CcdiProjectOverviewController extends BaseController {
return AjaxResult.success(people);
}
/**
* 查询外部人员风险模型卡片
*/
@GetMapping("/external-risk-models/cards")
@Operation(summary = "查询外部人员风险模型卡片")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getExternalRiskModelCards(Long 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) {
CcdiProjectRiskModelPeopleVO people = overviewService.getExternalRiskModelPeople(queryDTO);
return AjaxResult.success(people);
}
/**
* 查询项目分析详情
*/
@@ -173,6 +223,48 @@ public class CcdiProjectOverviewController extends BaseController {
util.exportExcel(response, rows, "风险人员总览");
}
/**
* 导出外部人员预警
*/
@PostMapping("/external-persons/export")
@Operation(summary = "导出外部人员预警")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportExternalPersons(HttpServletResponse response, Long 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) {
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
) {
List<CcdiProjectRiskModelPeopleExcel> rows = overviewService.exportExternalRiskModelPeople(queryDTO);
ExcelUtil<CcdiProjectRiskModelPeopleExcel> util =
new ExcelUtil<>(CcdiProjectRiskModelPeopleExcel.class);
util.exportExcel(response, rows, "外部人员风险模型命中人员");
}
/**
* 导出风险明细
*/

View File

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

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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;
@@ -58,7 +59,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());
@@ -111,9 +112,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("模型名称", "预警数量", "涉及人员"),
@@ -144,6 +145,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 {
@@ -252,6 +287,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

@@ -4,12 +4,16 @@ 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.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,14 +29,21 @@ 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;
@@ -214,6 +225,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
@@ -241,7 +354,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();
}
@@ -332,9 +445,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
.toList());
report.setParams(buildReportParams(projectId));
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)
));
@@ -435,6 +551,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);
}
@@ -447,6 +608,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");
@@ -516,15 +685,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;
}
@@ -597,6 +769,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

@@ -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

@@ -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,401 @@
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
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
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 distinct
source.model_code as modelCode,
source.model_name as modelName,
source.rule_code as ruleCode,
source.rule_name as ruleName,
source.risk_level as riskLevel
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_code asc, source.rule_code asc
</select>
<sql id="suspiciousTransactionBaseSql">
select
bs.bank_statement_id as bankStatementId,
@@ -606,6 +1040,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 +1114,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 +1189,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>
@@ -1073,4 +1559,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

@@ -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

@@ -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,8 +11,10 @@ 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 java.lang.reflect.Method;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -19,6 +22,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.assertTrue;
class CcdiProjectOverviewReportPdfExporterTest {
@@ -36,6 +40,54 @@ class CcdiProjectOverviewReportPdfExporterTest {
assertTrue(response.getContentAsByteArray().length > 1000);
}
@Test
void shouldUseOverallRiskSummaryInReportMetrics() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter();
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();
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();
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(
@@ -47,6 +99,44 @@ class CcdiProjectOverviewReportPdfExporterTest {
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);
}
private CcdiProjectOverviewReportVO buildReport() {
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
CcdiProject project = new CcdiProject();
@@ -73,22 +163,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("测试主体");
@@ -117,6 +218,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("张三");
@@ -130,6 +240,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

@@ -16,6 +16,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.CcdiProjectPersonAnalysisDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
@@ -250,17 +251,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("李四");
@@ -282,14 +286,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

@@ -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,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,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,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,109 @@
# 2026-06-30 上线前总体验收记录
## 1. 验收范围
本次验收覆盖 2026-06-29 至 2026-06-30 本地提交与未提交变更,重点包括:
- 项目分析预警交互:风险模型区、项目分析弹窗、外部人员详情页、资金图谱页签。
- 外部人员预警外部人员识别、7 条外部人员流水打标规则、外部人员总览与模型人员列表。
- 涉疑交易导出:涉疑交易明细导出字段调整为接近流水明细,并保留关联员工与异常标签。
- 报告导出:项目总览 PDF 与风险明细工作簿外部人员相关内容。
- 专项分析员工范围:专项分析仅统计项目范围员工。
- 前端页面文案与联动:详情页关闭/返回、外部人员不再跳流水明细、统计卡片调整。
## 2. 上线验收清单
### 2.1 变更范围与生产包清单
- [ ] 已识别本次真实业务变更文件。
- [ ] 已识别不应进入生产提交/制品的本地生成物。
- [ ] SQL 迁移脚本与代码规则口径一致。
- [ ] 配置文件变更已确认是否需要进入生产。
### 2.2 数据库与 SQL
- [ ] 外部人员 7 条规则可幂等写入 `ccdi_bank_tag_rule`
- [ ] 外部人员主体口径:`bs.cret_no` 非空,且不是员工、不是员工亲属。
- [ ] 外部人员与员工/亲属交易规则不使用对手方证件号。
- [ ] 对手方关联对象账号优先:账号命中信贷客户时显示信贷客户;员工/亲属账号优先,账号未命中时才按名称。
- [ ] 对象级外部人员命中可进入结果总览统计。
### 2.3 后端规则与接口
- [ ] 7 条外部人员规则能被打标服务分发。
- [ ] 外部人员阈值读取正确:大额类复用 `LARGE_TRANSACTION`,疑似赌博对象规则复用 `SUSPICIOUS_GAMBLING`
- [ ] 涉疑交易导出字段包含流水基础字段、关联员工、异常标签。
- [ ] 风险明细工作簿导出仍保留员工负面征信和异常账户 sheet。
- [ ] PDF 报告外部人员汇总与明细输出正常。
### 2.4 前端页面
- [ ] 结果总览统计卡片展示符合最新口径。
- [ ] 外部人员详情页查看异常明细,不跳转流水明细。
- [ ] 外部人员详情页关闭/返回入口可用。
- [ ] 资金图谱页签外部人员场景显示为资金,不出现“关联对象”等不清晰文案。
- [ ] 风险模型区员工模型与外部人员模型展示正常。
### 2.5 构建与自动化测试
- [ ] 后端相关单元测试通过。
- [ ] 后端模块编译通过。
- [ ] 主应用打包通过。
- [ ] 前端生产构建通过。
- [ ] 本地后端启动并登录接口可用。
- [ ] 浏览器打开真实页面完成基本冒烟验证。
## 3. 执行记录
### 3.1 本地提交范围
2026-06-29 至 2026-06-30 本地提交共 4 个:
- `dc5574cd` 调整专项分析员工范围口径。
- `8caf914b` 完善项目分析功能清单。
- `2efaf573` 完善外部人员详情与预警显示。
- `0ceddbc3` 完善项目分析预警交互。
### 3.2 数据库与 SQL 验收
- [x] 使用 `bin/mysql_utf8_exec.sh` 执行 `sql/migration/2026-06-29-add-external-person-bank-tag-rules.sql` 成功。
- [x] 数据库 `ccdi_bank_tag_rule` 已存在 7 条外部人员规则,且均为启用状态。
- [x] 外部人员大额类规则风险等级调整为中风险,员工/亲属资金往来和同日多对手方疑似赌博交易保持高风险。
- [x] 外部人员主体范围 SQL 使用 `bs.cret_no is not null` 且排除员工、员工亲属。
- [x] 外部人员与员工/亲属交易规则未使用对手方证件号;账号命中优先,账号未命中时才按名称命中员工或亲属。
- [x] 外部人员结果总览 SQL 包含流水级命中和 `EXTERNAL_CERT_NO` 对象级命中。
### 3.3 后端验收
- [x] 外部人员 7 条规则已接入 `CcdiBankTagServiceImpl` 分发逻辑。
- [x] 阈值映射已验证:外部单笔/累计/年流水复用 `LARGE_TRANSACTION`;外部多对手方疑似赌博复用 `SUSPICIOUS_GAMBLING`
- [x] 涉疑交易导出接口验收通过,`涉疑交易明细` sheet 表头为:交易时间、本方账户、本方主体、对方名称、对方账户、关联员工、摘要、交易类型、异常标签、交易金额。
- [x] 风险明细工作簿仍保留 `员工负面征信信息``异常账户人员信息` 两个 sheet。
- [x] 接口烟测通过:`/external-persons/summary``/external-persons``/external-risk-models/cards``/suspicious-transactions` 对项目 `90629003``90629002` 返回 `code=200`
### 3.4 前端验收
- [x] 真实页面登录成功,进入项目 `90629002`
- [x] 结果总览展示总人数、风险等级、外部人员统计;外部人员预警页签可见。
- [x] 外部人员列表展示主体类型、涉及对象、最近交易时间;涉及对象显示为员工、员工亲属、中介库人员、资金等业务词。
- [x] 外部人员查看详情打开弹窗,不跳转流水明细查询。
- [x] 外部人员详情页右上角存在关闭按钮,异常明细按流水明细表格展示。
- [x] 风险模型区域员工模型与外部模型均可展示;验收中发现 duplicate key 后已修复,复验控制台 0 error、0 warning。
### 3.5 构建与测试
- [x] 后端针对性单测通过13 个测试类、82 个用例0 失败、0 错误。
- [x] `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- [x] `mvn -pl ruoyi-admin -am package -DskipTests` 通过。
- [x] `npm run build:prod` 使用 Node `22.22.3` 构建通过。
- [x] 浏览器真实页面验收通过,截图保存在 `output/playwright/2026-06-30-external-person-detail.png`
## 4. 风险与处理
- [ ] 生产包阻断:`ruoyi-ui/public/!cLEZGP.docx``0uS5znL.docx``AlhOgYW.docx` 会被复制到 `ruoyi-ui/dist/`,必须在生产打包前移除或排除。
- [ ] 生产提交阻断:工作区存在多处未跟踪随机 `.docx` 文件,分布在 `assets/``lsfx-mock-server/``ruoyi-admin/src/main/resources/``ruoyi-ui/public/`,不得纳入生产提交。
- [ ] 配置确认项:`tongweb_62318.properties` 记录了本机路径 `C:\Users\20696\Desktop\chuhe\ccdi`,不得作为生产配置提交。
- [ ] 配置确认项:`ruoyi-admin/src/main/resources/application-dev.yml` 新增 `spring.main.allow-circular-references: true`,需确认该 dev 配置变更是否允许进入生产分支。
- [ ] 既有构建警告:`ccdi-info-collection/pom.xml` 重复声明 `ccdi-lsfx` 依赖,当前不阻断本次构建,但建议上线前单独清理。
结论:业务功能链路验收通过;当前工作区不能直接整体打包上线,必须先剔除上述生产包/提交阻断项。

View File

@@ -20,6 +20,26 @@ export function getOverviewRiskPeople(params) {
})
}
export function getOverviewExternalPersons(params) {
return request({
url: '/ccdi/project/overview/external-persons',
method: 'get',
params: {
projectId: params.projectId,
pageNum: params.pageNum,
pageSize: params.pageSize
}
})
}
export function getOverviewExternalRiskSummary(projectId) {
return request({
url: '/ccdi/project/overview/external-persons/summary',
method: 'get',
params: { projectId }
})
}
export function getOverviewRiskModelCards(projectId) {
return request({
url: '/ccdi/project/overview/risk-models/cards',
@@ -28,6 +48,14 @@ export function getOverviewRiskModelCards(projectId) {
})
}
export function getOverviewExternalRiskModelCards(projectId) {
return request({
url: '/ccdi/project/overview/external-risk-models/cards',
method: 'get',
params: { projectId }
})
}
export function getOverviewRiskModelPeople(params) {
return request({
url: '/ccdi/project/overview/risk-models/people',
@@ -44,6 +72,21 @@ export function getOverviewRiskModelPeople(params) {
})
}
export function getOverviewExternalRiskModelPeople(params) {
return request({
url: '/ccdi/project/overview/external-risk-models/people',
method: 'get',
params: {
projectId: params.projectId,
modelCodes: params.modelCodes,
matchMode: params.matchMode,
keyword: params.keyword,
pageNum: params.pageNum,
pageSize: params.pageSize
}
})
}
export function getOverviewPersonAnalysisDetail(params) {
return request({
url: '/ccdi/project/overview/person-analysis/detail',

View File

@@ -130,6 +130,7 @@ export function download(url, params, filename, config) {
transformRequest: [(params) => { return tansParams(params) }],
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
responseType: 'blob',
timeout: 120000,
...config
}).then(async (data) => {
const isBlob = blobValidate(data)

View File

@@ -152,7 +152,6 @@
<div class="shell-header">
<div class="shell-title-group">
<span class="shell-title">流水明细查询</span>
<span class="shell-subtitle">按项目范围查询交易明细并查看详情</span>
</div>
<el-button
size="small"
@@ -164,6 +163,14 @@
导出流水
</el-button>
</div>
<el-alert
v-if="prefillTip"
:closable="false"
class="prefill-alert"
show-icon
type="info"
:title="prefillTip"
/>
<el-tabs v-model="activeTab" class="result-tabs" @tab-click="handleTabChange">
<el-tab-pane label="全部" name="all" />
@@ -499,6 +506,10 @@ export default {
projectStatus: "0",
}),
},
detailQueryPrefill: {
type: Object,
default: null,
},
},
data() {
return {
@@ -511,6 +522,7 @@ export default {
list: [],
total: 0,
listError: "",
prefillTip: "",
detailData: createEmptyDetailData(),
queryParams: createDefaultQueryParams(this.projectId),
optionData: createEmptyOptionData(),
@@ -530,6 +542,13 @@ export default {
this.getOptions();
this.getList();
},
detailQueryPrefill: {
immediate: true,
deep: true,
handler(value) {
this.applyDetailQueryPrefill(value);
},
},
},
methods: {
buildFlowEvidenceFingerprint,
@@ -596,10 +615,25 @@ export default {
this.activeTab = "all";
this.dateRange = [];
this.queryParams = createDefaultQueryParams(this.projectId);
this.prefillTip = "";
this.syncProjectId();
this.getOptions();
this.getList();
},
applyDetailQueryPrefill(value) {
if (!value || !Array.isArray(value.ourCertNos) || !value.ourCertNos.length) {
return;
}
this.activeTab = "all";
this.dateRange = [];
this.queryParams = {
...createDefaultQueryParams(this.projectId),
ourCertNos: value.ourCertNos,
};
this.prefillTip = `当前查看:${value.title || "外部人员"} 的本方流水`;
this.syncProjectId();
this.getList();
},
handleTabChange(tab) {
const tabName = tab && tab.name ? tab.name : this.activeTab;
this.activeTab = tabName;

View File

@@ -0,0 +1,461 @@
<template>
<el-dialog
title="外部人员详情"
:visible.sync="visibleProxy"
width="88%"
top="2vh"
append-to-body
custom-class="project-analysis-dialog external-person-analysis-dialog"
@close="handleClosed"
>
<div class="project-analysis-dialog__body external-analysis-dialog__body">
<div class="project-analysis-header">
<div class="project-analysis-header__main">
<div class="project-analysis-header__title-group">
<div class="project-analysis-header__eyebrow">结果总览</div>
<div class="project-analysis-header__title">外部人员详情</div>
</div>
<button class="project-analysis-header__close" type="button" aria-label="关闭" @click="closeDialog">
<i class="el-icon-close" />
</button>
</div>
</div>
<div class="project-analysis-workspace">
<project-analysis-sidebar
class="project-analysis-layout__sidebar"
:sidebar-data="sidebarData"
:field-labels="sidebarFieldLabels"
/>
<div class="project-analysis-layout__main">
<el-alert
v-if="detailError"
:closable="false"
class="external-detail-alert"
type="error"
show-icon
:title="detailError"
/>
<el-tabs v-model="activeTab" class="project-analysis-tabs" stretch @tab-click="handleTabChange">
<el-tab-pane label="异常明细" name="abnormalDetail">
<div v-if="detailLoading" v-loading="detailLoading" class="external-detail-loading" />
<div v-else>
<project-analysis-abnormal-tab
:detail-data="abnormalDetailData"
:person="normalizedPerson"
:project-id="projectId"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
</el-tab-pane>
<el-tab-pane label="资金流向" name="fund">
<project-analysis-fund-flow-tab
v-if="activeTab === 'fund'"
ref="fundFlowTab"
:project-id="projectId"
:person="normalizedPerson"
:model-summary="fundModelSummary"
/>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</el-dialog>
</template>
<script>
import { listBankStatement } from "@/api/ccdiProjectBankStatement";
import ProjectAnalysisAbnormalTab from "./ProjectAnalysisAbnormalTab";
import ProjectAnalysisFundFlowTab from "./ProjectAnalysisFundFlowTab";
import ProjectAnalysisSidebar from "./ProjectAnalysisSidebar";
export default {
name: "ExternalPersonDetailDialog",
components: {
ProjectAnalysisAbnormalTab,
ProjectAnalysisFundFlowTab,
ProjectAnalysisSidebar,
},
props: {
visible: {
type: Boolean,
default: false,
},
person: {
type: Object,
default: () => ({}),
},
projectId: {
type: [String, Number],
default: null,
},
projectName: {
type: String,
default: "",
},
},
data() {
return {
activeTab: "abnormalDetail",
detailLoading: false,
detailError: "",
statementRows: [],
};
},
computed: {
visibleProxy: {
get() {
return this.visible;
},
set(value) {
this.$emit("update:visible", value);
},
},
displayName() {
return (this.person && (this.person.name || this.person.staffName)) || "外部人员";
},
displayCertNo() {
return (this.person && (this.person.idNo || this.person.staffIdCard)) || "-";
},
displaySubjectType() {
return (this.person && (this.person.subjectType || this.person.staffCode)) || "外部人员";
},
relatedObject() {
return (this.person && (this.person.relatedObject || this.person.department)) || "-";
},
normalizedPerson() {
return {
...(this.person || {}),
name: this.displayName,
staffName: this.displayName,
idNo: this.displayCertNo,
staffIdCard: this.displayCertNo,
staffCode: this.displaySubjectType,
department: this.relatedObject,
};
},
riskTags() {
if (Array.isArray(this.person && this.person.riskPointTagList)) {
return this.person.riskPointTagList;
}
if (Array.isArray(this.person && this.person.hitTagList)) {
return this.person.hitTagList;
}
return [];
},
statementTagKeys() {
const keys = new Set();
this.statementRows.forEach((row) => {
if (!Array.isArray(row && row.hitTags)) {
return;
}
row.hitTags.forEach((tag) => {
this.buildTagKeys(tag).forEach((key) => keys.add(key));
});
});
return keys;
},
sidebarFieldLabels() {
return {
staffCode: "主体类型",
department: "证件号",
projectName: "所属项目",
};
},
sidebarData() {
return {
basicInfo: {
name: this.displayName,
riskLevel: (this.person && this.person.riskLevel) || "-",
staffCode: this.displaySubjectType,
department: this.displayCertNo,
projectName: this.projectName || "-",
},
modelSummary: {
modelCount: (this.person && this.person.modelCount) || 0,
riskTags: this.riskTags,
},
};
},
abnormalDetailData() {
const statementGroup = this.buildStatementGroup();
const objectRecords = this.riskTags
.filter((tag) => !this.hasMatchedStatementTag(tag))
.map((tag, index) => this.buildObjectAbnormalRecord(tag, index));
const groups = [];
if (statementGroup.records.length) {
groups.push(statementGroup);
}
if (objectRecords.length) {
groups.push({
groupCode: "EXTERNAL_OBJECT_WARNING",
groupName: "对象异常明细",
groupType: "OBJECT",
records: objectRecords,
});
}
return {
groups,
};
},
fundModelSummary() {
return {
staffIdCard: this.displayCertNo,
modelCount: (this.person && this.person.modelCount) || this.riskTags.length,
riskTags: this.riskTags,
};
},
},
watch: {
visible(value) {
if (value) {
this.activeTab = "abnormalDetail";
this.loadStatementRows();
}
},
},
methods: {
async loadStatementRows() {
if (!this.projectId || !this.displayCertNo || this.displayCertNo === "-") {
this.statementRows = [];
return;
}
this.statementRows = [];
this.detailLoading = true;
this.detailError = "";
try {
const response = await listBankStatement({
projectId: this.projectId,
ourCertNos: [this.displayCertNo],
pageNum: 1,
pageSize: 200,
});
this.statementRows = Array.isArray(response && response.rows) ? response.rows : [];
} catch (error) {
this.statementRows = [];
this.detailError = "外部人员流水异常明细加载失败,请稍后重试";
console.error("加载外部人员流水异常明细失败", error);
} finally {
this.detailLoading = false;
}
},
buildStatementGroup() {
const records = this.statementRows
.map((row) => this.normalizeStatementRow(row))
.filter((row) => row.hitTags.length);
return {
groupCode: "EXTERNAL_BANK_STATEMENT",
groupName: "流水异常明细",
groupType: "BANK_STATEMENT",
records,
};
},
normalizeStatementRow(row) {
const hitTags = Array.isArray(row && row.hitTags)
? row.hitTags.filter((tag) => this.isMatchedRiskTag(tag))
.map((tag) => this.enrichStatementTag(tag))
: [];
return {
...row,
hitTags,
};
},
isMatchedRiskTag(statementTag) {
const statementKeys = this.buildTagKeys(statementTag);
return this.riskTags.some((riskTag) => {
const riskKeys = this.buildTagKeys(riskTag);
return statementKeys.some((key) => riskKeys.includes(key));
});
},
hasMatchedStatementTag(riskTag) {
const riskKeys = this.buildTagKeys(riskTag);
return riskKeys.some((key) => this.statementTagKeys.has(key));
},
buildTagKeys(tag) {
if (!tag) {
return [];
}
if (typeof tag === "string") {
return [`name:${tag}`];
}
return [
tag.ruleCode ? `code:${tag.ruleCode}` : "",
tag.ruleName ? `name:${tag.ruleName}` : "",
].filter(Boolean);
},
enrichStatementTag(tag) {
const matched = this.riskTags.find((item) => item && (
(tag.ruleCode && item.ruleCode === tag.ruleCode)
|| (tag.ruleName && item.ruleName === tag.ruleName)
));
return {
...tag,
modelCode: tag.modelCode || (matched && matched.modelCode),
modelName: tag.modelName || (matched && matched.modelName),
};
},
buildObjectAbnormalRecord(tag, index) {
const safeTag = typeof tag === "object" && tag ? tag : {};
const modelName = safeTag.modelName || "外部人员模型";
const ruleName = safeTag.ruleName || this.formatRiskTag(tag) || "外部人员预警";
return {
modelCode: safeTag.modelCode || `EXTERNAL_MODEL_${index}`,
title: ruleName,
subtitle: modelName,
riskTags: [modelName, this.formatRiskLevel(safeTag.riskLevel)].filter(Boolean),
reasonDetail: safeTag.reasonDetail || this.buildReasonDetail(ruleName, modelName),
summary: ruleName,
extraFields: [],
};
},
buildReasonDetail(ruleName, modelName) {
const parts = [
this.displayName,
`命中${modelName}`,
ruleName,
].filter(Boolean);
return parts.join("");
},
formatRiskLevel(value) {
if (value === "HIGH") {
return "高风险";
}
if (value === "MEDIUM") {
return "中风险";
}
if (value === "LOW") {
return "低风险";
}
return "";
},
formatRiskTag(tag) {
if (typeof tag === "string") {
return tag;
}
if (tag && typeof tag === "object") {
return tag.ruleName || tag.modelName || tag.label || tag.name || "";
}
return "";
},
closeDialog() {
this.visibleProxy = false;
},
handleClosed() {
this.activeTab = "abnormalDetail";
this.detailLoading = false;
this.detailError = "";
this.statementRows = [];
},
handleTabChange() {
this.$nextTick(() => {
const fundFlowTab = this.$refs.fundFlowTab;
if (fundFlowTab && fundFlowTab.ensureGraphReady) {
fundFlowTab.ensureGraphReady();
}
});
},
},
};
</script>
<style lang="scss" scoped>
.project-analysis-dialog__body {
display: flex;
flex-direction: column;
min-height: calc(92vh - 64px);
border: 1px solid #dde3ec;
background: #ffffff;
overflow: hidden;
}
.project-analysis-header {
padding: 28px 40px 24px;
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
.project-analysis-header__main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.project-analysis-header__title-group {
min-width: 0;
}
.project-analysis-header__eyebrow {
color: #65758d;
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.project-analysis-header__title {
margin-top: 18px;
color: #101a2b;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.project-analysis-header__close {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: none;
border-radius: 2px;
background: transparent;
color: #8d99aa;
font-size: 18px;
line-height: 34px;
text-align: center;
cursor: pointer;
}
.project-analysis-header__close:hover {
background: #f2f5f8;
color: #245b8f;
}
.project-analysis-workspace {
display: flex;
align-items: flex-start;
gap: 36px;
min-height: 640px;
max-height: calc(92vh - 150px);
padding: 20px 40px 28px;
overflow: auto;
background: #ffffff;
}
.project-analysis-layout__sidebar {
flex: 0 0 34%;
max-width: 460px;
}
.project-analysis-layout__main {
flex: 1;
min-width: 0;
border-left: 1px solid #dde3ec;
padding-left: 36px;
}
.project-analysis-tabs {
margin-top: -2px;
}
.external-detail-alert {
margin-bottom: 12px;
}
.external-detail-loading {
min-height: 360px;
border: 1px solid #d9e1ed;
background: #ffffff;
}
</style>

View File

@@ -12,6 +12,7 @@
<div class="stats-content">
<div class="stats-label">{{ item.label }}</div>
<div class="stats-value">{{ item.value }}</div>
<div v-if="item.desc" class="stats-desc">{{ item.desc }}</div>
</div>
</div>
</div>
@@ -98,4 +99,12 @@ export default {
font-weight: 700;
color: var(--ccdi-text-primary);
}
.stats-desc {
margin-top: 6px;
font-size: 12px;
line-height: 1.2;
color: var(--ccdi-text-muted);
white-space: nowrap;
}
</style>

View File

@@ -30,18 +30,21 @@
导出报告
</el-button>
</div>
<overview-stats :summary="currentData.summary" />
<overview-stats :summary="visibleSummary" />
<risk-people-section
:project-id="projectId"
:section-data="currentData.riskPeople"
:selected-model-codes="selectedModelCodes"
@view-project-analysis="handleRiskPeopleProjectAnalysis"
@view-external-detail="handleExternalDetail"
@scope-summary-change="handleRiskPeopleScopeSummaryChange"
/>
</section>
<risk-model-section
:section-data="currentData.riskModels"
@selection-change="handleRiskModelSelectionChange"
@view-project-analysis="handleRiskModelProjectAnalysis"
@view-external-detail="handleExternalDetail"
/>
<risk-detail-section
:section-data="currentData.riskDetails"
@@ -58,6 +61,13 @@
@close="handleProjectAnalysisDialogClose"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
<external-person-detail-dialog
:visible.sync="externalDetailVisible"
:person="currentExternalPerson"
:project-id="projectId"
:project-name="projectInfo.projectName"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
</template>
@@ -70,6 +80,7 @@ import {
import {
getOverviewDashboard,
getOverviewEmployeeCreditNegative,
getOverviewExternalRiskSummary,
getOverviewRiskPeople,
getOverviewRiskModelCards,
getOverviewSuspiciousTransactions,
@@ -79,6 +90,7 @@ import RiskPeopleSection from "./RiskPeopleSection";
import RiskModelSection from "./RiskModelSection";
import RiskDetailSection from "./RiskDetailSection";
import ProjectAnalysisDialog from "./ProjectAnalysisDialog";
import ExternalPersonDetailDialog from "./ExternalPersonDetailDialog";
export default {
name: "PreliminaryCheck",
@@ -88,6 +100,7 @@ export default {
RiskModelSection,
RiskDetailSection,
ProjectAnalysisDialog,
ExternalPersonDetailDialog,
},
props: {
projectId: {
@@ -118,6 +131,15 @@ export default {
currentModel: "-",
riskTags: [],
},
riskPeopleScopeSummary: {
activeTab: "employee",
external: {
total: 0,
rows: [],
},
},
currentExternalPerson: null,
externalDetailVisible: false,
};
},
computed: {
@@ -127,6 +149,12 @@ export default {
}
return this.stateDataMap[this.pageState] || this.realData;
},
visibleSummary() {
return {
...this.currentData.summary,
stats: this.buildCombinedSummaryStats(),
};
},
},
watch: {
projectId(newVal) {
@@ -138,6 +166,7 @@ export default {
this.pageState = "empty";
this.selectedModelCodes = [];
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
},
},
created() {
@@ -148,17 +177,90 @@ export default {
this.realData = this.stateDataMap.empty;
this.pageState = "empty";
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
},
methods: {
handleRiskModelSelectionChange(modelCodes) {
this.selectedModelCodes = Array.isArray(modelCodes) ? [...modelCodes] : [];
},
handleRiskPeopleScopeSummaryChange(summary) {
this.riskPeopleScopeSummary = {
activeTab: summary && summary.activeTab ? summary.activeTab : "employee",
external: {
total: summary && summary.external ? summary.external.total || 0 : 0,
rows: summary && summary.external && Array.isArray(summary.external.rows)
? summary.external.rows
: [],
},
};
},
buildCombinedSummaryStats() {
const external = (this.currentData.summary && this.currentData.summary.externalRiskSummary) || {};
const employeeStats = ((this.currentData.summary && this.currentData.summary.stats) || []);
const statMap = employeeStats.reduce((result, item) => {
result[item.key] = item;
return result;
}, {});
const high = Number((statMap.riskPeople && statMap.riskPeople.value) || 0);
const medium = Number((statMap.medium && statMap.medium.value) || 0);
const low = Number((statMap.low && statMap.low.value) || 0);
const noRisk = Number((statMap.count && statMap.count.value) || 0);
const externalTotal = Number(external.total || 0);
const externalHigh = Number(external.high || 0);
const externalMedium = Number(external.medium || 0);
const externalLow = Number(external.low || 0);
const externalNoRisk = Number(external.noRisk || 0);
const buildSplit = (employeeValue, externalValue) => (externalTotal > 0
? `员工 ${employeeValue} / 外部 ${externalValue}`
: "");
return [
{
...(statMap.people || {}),
key: "people",
label: "总人数",
value: Number((statMap.people && statMap.people.value) || 0) + externalTotal,
desc: buildSplit(Number((statMap.people && statMap.people.value) || 0), externalTotal),
},
{
...(statMap.riskPeople || {}),
key: "riskPeople",
label: "高风险",
value: high + externalHigh,
desc: buildSplit(high, externalHigh),
},
{
...(statMap.medium || {}),
key: "medium",
label: "中风险",
value: medium + externalMedium,
desc: buildSplit(medium, externalMedium),
},
{
...(statMap.low || {}),
key: "low",
label: "低风险",
value: low + externalLow,
desc: buildSplit(low, externalLow),
},
{
...(statMap.count || {}),
key: "count",
label: "无风险",
value: noRisk + externalNoRisk,
desc: buildSplit(noRisk, externalNoRisk),
},
];
},
handleRiskPeopleProjectAnalysis(row) {
this.openProjectAnalysisDialog("riskPeople", row);
},
handleRiskModelProjectAnalysis(row) {
this.openProjectAnalysisDialog("riskModelPeople", row);
},
handleExternalDetail(row) {
this.currentExternalPerson = row || null;
this.externalDetailVisible = true;
},
openProjectAnalysisDialog(source, person) {
this.projectAnalysisSource = source || "riskPeople";
this.currentProjectAnalysisPerson = person || null;
@@ -201,6 +303,10 @@ export default {
riskTags,
};
},
resetExternalDetailDialog() {
this.externalDetailVisible = false;
this.currentExternalPerson = null;
},
handleOverviewReportExport() {
if (!this.projectId) {
return;
@@ -219,14 +325,30 @@ export default {
this.pageState = "empty";
this.selectedModelCodes = [];
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
return;
}
this.pageState = "loading";
this.selectedModelCodes = [];
this.riskPeopleScopeSummary = {
activeTab: "employee",
external: {
total: 0,
rows: [],
},
};
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
try {
const [dashboardRes, riskPeopleRes, riskModelCardsRes, suspiciousRes, creditNegativeRes] = await Promise.all([
const [
dashboardRes,
riskPeopleRes,
riskModelCardsRes,
suspiciousRes,
creditNegativeRes,
externalSummaryRes,
] = await Promise.all([
getOverviewDashboard(this.projectId),
getOverviewRiskPeople({
projectId: this.projectId,
@@ -245,12 +367,14 @@ export default {
pageNum: 1,
pageSize: 5,
}),
getOverviewExternalRiskSummary(this.projectId),
]);
const dashboardData = (dashboardRes && dashboardRes.data) || {};
const riskPeopleData = (riskPeopleRes && riskPeopleRes.data) || {};
const riskModelCardsData = (riskModelCardsRes && riskModelCardsRes.data) || {};
const suspiciousData = (suspiciousRes && suspiciousRes.data) || {};
const creditNegativeData = (creditNegativeRes && creditNegativeRes.data) || {};
const externalRiskSummary = (externalSummaryRes && externalSummaryRes.data) || {};
this.realData = createOverviewLoadedData({
projectId: this.projectId,
@@ -259,6 +383,7 @@ export default {
riskModelCardsData,
suspiciousData,
creditNegativeData,
externalRiskSummary,
});
const hasOverviewData = Boolean(
@@ -273,6 +398,7 @@ export default {
this.pageState = "empty";
this.selectedModelCodes = [];
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
console.error("加载结果总览失败", error);
}
},

View File

@@ -24,6 +24,9 @@
{{ dialogData.sourceSummary.currentModelValue }}
</span>
</div>
<button class="project-analysis-header__close" type="button" aria-label="关闭" @click="closeDialog">
<i class="el-icon-close" />
</button>
</div>
</div>
<div v-loading="detailLoading" class="project-analysis-workspace">
@@ -224,6 +227,9 @@ export default {
this.resetDialogState();
this.$emit("close");
},
closeDialog() {
this.visibleProxy = false;
},
handleTabChange() {
this.$nextTick(() => {
const tabRef = this.activeTab === "relationshipGraph"
@@ -303,6 +309,25 @@ export default {
font-weight: 600;
}
.project-analysis-header__close {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: none;
border-radius: 2px;
background: transparent;
color: #8d99aa;
font-size: 18px;
line-height: 34px;
text-align: center;
cursor: pointer;
}
.project-analysis-header__close:hover {
background: #f2f5f8;
color: #245b8f;
}
.project-analysis-workspace {
display: flex;
align-items: flex-start;

View File

@@ -51,7 +51,7 @@ export default {
},
computed: {
isFundOnly() {
return this.initialGraphTab === "fund" && this.graphTabs.length === 2;
return this.initialGraphTab === "fund";
},
rootClasses() {
return {

View File

@@ -11,15 +11,15 @@
</div>
<div class="sidebar-profile__meta">
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">工号</span>
<span class="sidebar-profile__label">{{ normalizedFieldLabels.staffCode }}</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.staffCode || "-" }}</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">部门</span>
<span class="sidebar-profile__label">{{ normalizedFieldLabels.department }}</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.department || "-" }}</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">所属项目</span>
<span class="sidebar-profile__label">{{ normalizedFieldLabels.projectName }}</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.projectName || "-" }}</span>
</div>
</div>
@@ -63,6 +63,23 @@ export default {
},
}),
},
fieldLabels: {
type: Object,
default: () => ({
staffCode: "工号",
department: "部门",
projectName: "所属项目",
}),
},
},
computed: {
normalizedFieldLabels() {
return {
staffCode: this.fieldLabels.staffCode || "工号",
department: this.fieldLabels.department || "部门",
projectName: this.fieldLabels.projectName || "所属项目",
};
},
},
methods: {
formatRiskTag(tag) {

View File

@@ -351,6 +351,7 @@ const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "ALL", label: "全部可疑人员类型" },
{ value: "NAME_LIST", label: "名单库命中" },
{ value: "MODEL_RULE", label: "模型规则命中" },
{ value: "EXTERNAL_PERSON", label: "外部人员预警" },
];
const normalizeSuspiciousType = (value) => (

View File

@@ -8,160 +8,209 @@
</div>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">模型预警次数统计</div>
<div class="block-subtitle">模型汇总预警命中次数与涉及人数</div>
</div>
<el-button size="mini" type="text">导出</el-button>
</div>
<div v-loading="cardLoading" class="model-card-grid">
<button
v-for="item in cards"
:key="item.key"
type="button"
class="model-card"
:class="{ 'is-active': isModelSelected(item.key) }"
@click="toggleModelSelection(item.key)"
>
<div class="model-card-title">{{ item.title }}</div>
<div class="model-card-count">{{ item.count }}</div>
<div class="model-card-meta">涉及 {{ item.peopleCount }} </div>
<div class="model-card-action">
{{ isModelSelected(item.key) ? "再次点击取消" : "点击加入联动" }}
<div class="model-workbench">
<section class="model-list-panel">
<div class="panel-header">
<div>
<div class="panel-title">模型预警统计</div>
<div class="panel-subtitle">点击模型筛选下方命中人员</div>
</div>
</button>
</div>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">命中模型涉及人员</div>
<div class="block-subtitle">基于筛选条件查看模型命中人员</div>
</div>
</div>
<div class="filter-bar">
<div class="filter-item filter-item--keyword">
<span class="filter-label">员工姓名或工号</span>
<el-input
v-model.trim="keyword"
size="mini"
clearable
placeholder="请输入员工姓名或工号"
@keyup.enter.native="handleQuery"
/>
<el-button size="mini" type="text" :disabled="!projectId" @click="handleModelExport">导出</el-button>
</div>
<div class="filter-item filter-item--dept">
<span class="filter-label">部门</span>
<el-select
v-model="deptId"
size="mini"
clearable
filterable
:loading="deptLoading"
placeholder="请选择部门"
<div v-loading="cardLoading" class="model-card-grid">
<button
v-for="item in displayCards"
:key="item.key"
type="button"
class="model-card"
:class="{ 'is-active': isModelSelected(item.key), 'is-external': item.sourceType === 'external' }"
@click="toggleModelSelection(item.key)"
>
<el-option
v-for="item in deptOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="model-card-head">
<span v-if="item.sourceType === 'external'" class="source-pill is-external">
{{ formatSourceLabel(item) }}
</span>
<span class="model-card-action">{{ isModelSelected(item.key) ? "已筛选" : "筛选" }}</span>
</span>
<span class="model-card-title">{{ item.title }}</span>
<span class="model-card-metrics">
<span><strong>{{ item.count }}</strong><em></em></span>
<span><strong>{{ item.peopleCount }}</strong><em></em></span>
</span>
</button>
</div>
</section>
<section class="model-result-panel">
<div class="panel-header">
<div>
<div class="panel-title">命中模型涉及人员</div>
<div class="panel-subtitle">按模型人员信息和触发方式查看明细</div>
</div>
</div>
<div class="filter-item filter-item--mode">
<span class="filter-label">触发方式</span>
<el-radio-group
v-model="matchMode"
size="mini"
@change="handleMatchModeChange"
>
<el-radio-button label="ANY">任意触发</el-radio-button>
<el-radio-button label="ALL">同时触发</el-radio-button>
</el-radio-group>
</div>
<div class="filter-summary">
<span class="summary-label">已选模型</span>
<span class="summary-value">{{ selectedModelText }}</span>
</div>
<div class="filter-actions">
<el-button size="mini" type="primary" @click="handleQuery">查询</el-button>
<el-button size="mini" plain @click="resetQuery">重置</el-button>
</div>
</div>
<el-table v-loading="tableLoading" :data="peopleList" class="model-table">
<template slot="empty">
<el-empty :image-size="80" description="当前筛选条件下暂无命中人员" />
</template>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="staffCode" label="工号" min-width="120" />
<el-table-column prop="idNo" label="身份证号" min-width="180" />
<el-table-column prop="department" label="所属部门" min-width="140" />
<el-table-column label="命中模型" min-width="180">
<template slot-scope="scope">
<span>{{ formatModelNames(scope.row.modelNames) }}</span>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220">
<template slot-scope="scope">
<div v-if="scope.row.hitTagList && scope.row.hitTagList.length" class="hit-tag-list">
<div class="selected-strip">
<span class="selected-label">当前筛选</span>
<div class="selected-content">
<template v-if="selectedCards.length">
<el-tag
v-for="item in selectedCards"
:key="`selected-${item.key}`"
size="mini"
closable
effect="plain"
@close="toggleModelSelection(item.key)"
>
{{ item.title }}
</el-tag>
</template>
<span v-else class="selected-placeholder">全部模型</span>
</div>
<el-button v-if="selectedCards.length" size="mini" type="text" @click="clearSelectedModels">
清空模型
</el-button>
</div>
<div class="filter-bar">
<div class="filter-item filter-item--keyword">
<span class="filter-label">{{ keywordLabel }}</span>
<el-input
v-model.trim="keyword"
size="mini"
clearable
:placeholder="keywordPlaceholder"
@keyup.enter.native="handleQuery"
/>
</div>
<div v-if="activeSourceType === 'employee'" class="filter-item filter-item--dept">
<span class="filter-label">部门</span>
<el-select
v-model="deptId"
size="mini"
clearable
filterable
:loading="deptLoading"
placeholder="请选择部门"
>
<el-option
v-for="item in deptOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="filter-item filter-item--mode">
<span class="filter-label">模型关系</span>
<el-radio-group
v-model="matchMode"
size="mini"
@change="handleMatchModeChange"
>
<el-radio-button label="ANY">命中任一模型</el-radio-button>
<el-radio-button label="ALL">同时命中全部</el-radio-button>
</el-radio-group>
</div>
<div class="filter-actions">
<el-button size="mini" type="primary" @click="handleQuery">查询</el-button>
<el-button size="mini" plain @click="resetQuery">重置</el-button>
</div>
</div>
<el-table v-loading="tableLoading" :data="peopleList" class="model-table">
<template slot="empty">
<el-empty :image-size="80" description="当前筛选条件下暂无命中人员" />
</template>
<el-table-column type="index" label="序号" width="60" />
<el-table-column v-if="activeSourceType === 'all'" label="来源" width="84">
<template slot-scope="scope">
<el-tag
v-for="(tag, index) in scope.row.hitTagList"
:key="`${scope.row.staffCode || scope.row.idNo || index}-tag-${index}`"
size="mini"
effect="plain"
:type="resolveModelTagType(tag)"
:type="scope.row.sourceType === 'external' ? 'success' : ''"
>
{{ tag.ruleName }}
{{ scope.row.sourceType === "external" ? "外部" : "员工" }}
</el-tag>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewProject(scope.row)">{{
scope.row.actionLabel || "查看项目"
}}</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column
v-if="activeSourceType !== 'external'"
prop="staffCode"
:label="activeSourceType === 'all' ? '工号/主体类型' : '工号'"
min-width="120"
/>
<el-table-column prop="idNo" label="身份证号" min-width="180" />
<el-table-column
prop="department"
:label="departmentLabel"
min-width="140"
/>
<el-table-column label="命中模型" min-width="180">
<template slot-scope="scope">
<span>{{ formatModelNames(scope.row.modelNames) }}</span>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220">
<template slot-scope="scope">
<div v-if="scope.row.hitTagList && scope.row.hitTagList.length" class="hit-tag-list">
<el-tag
v-for="(tag, index) in scope.row.hitTagList"
:key="`${scope.row.staffCode || scope.row.idNo || index}-tag-${index}`"
size="mini"
effect="plain"
:type="resolveModelTagType(tag)"
>
{{ tag.ruleName }}
</el-tag>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewProject(scope.row)">{{
scope.row.actionLabel || "查看项目"
}}</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
layout="prev, pager, next"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
</div>
<div class="pagination-bar">
<el-pagination
background
layout="prev, pager, next"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
</div>
</section>
</div>
</div>
</section>
</template>
<script>
import { getOverviewRiskModelPeople } from "@/api/ccdi/projectOverview";
import {
getOverviewExternalRiskModelCards,
getOverviewExternalRiskModelPeople,
getOverviewRiskModelPeople,
} from "@/api/ccdi/projectOverview";
import { deptTreeSelect } from "@/api/system/user";
function normalizePeopleRows(rows) {
function normalizePeopleRows(rows, sourceType) {
if (!Array.isArray(rows)) {
return [];
}
return rows.map((item) => ({
...item,
sourceType,
name: item.staffName || item.name || "",
staffCode: item.staffCode || "",
modelNames: Array.isArray(item.modelNames) ? item.modelNames : [],
@@ -206,6 +255,7 @@ export default {
tableLoading: false,
deptLoading: false,
deptOptions: [],
externalCards: [],
peopleList: [],
total: 0,
};
@@ -218,22 +268,65 @@ export default {
if (!Array.isArray(this.sectionData && this.sectionData.cardList)) {
return [];
}
return this.sectionData.cardList.map((item) => ({
key: item.key || item.modelCode,
title: item.title || item.modelName,
count: item.count || item.warningCount || 0,
peopleCount: item.peopleCount || 0,
}));
return this.sectionData.cardList
.filter((item) => !String(item.key || item.modelCode || "").startsWith("EXTERNAL_"))
.map((item) => ({
key: item.key || item.modelCode,
title: item.title || item.modelName,
count: item.count || item.warningCount || 0,
peopleCount: item.peopleCount || 0,
sourceType: "employee",
}));
},
displayCards() {
return [...this.cards, ...this.externalCards];
},
activeSourceType() {
if (!this.selectedModelCodes.length) {
return "all";
}
const selected = this.displayCards.find((item) => this.selectedModelCodes.includes(item.key));
return selected && selected.sourceType === "external" ? "external" : "employee";
},
keywordLabel() {
if (this.activeSourceType === "external") {
return "姓名或证件号";
}
if (this.activeSourceType === "all") {
return "姓名/证件号/工号";
}
return "员工姓名或工号";
},
keywordPlaceholder() {
if (this.activeSourceType === "external") {
return "请输入姓名或证件号";
}
if (this.activeSourceType === "all") {
return "请输入姓名、证件号或工号";
}
return "请输入员工姓名或工号";
},
departmentLabel() {
if (this.activeSourceType === "external") {
return "涉及对象";
}
if (this.activeSourceType === "all") {
return "所属部门/涉及对象";
}
return "所属部门";
},
selectedModelText() {
if (!this.selectedModelCodes.length) {
return "全部模型";
}
return this.cards
return this.displayCards
.filter((item) => this.selectedModelCodes.includes(item.key))
.map((item) => item.title)
.join("、");
},
selectedCards() {
return this.displayCards.filter((item) => this.selectedModelCodes.includes(item.key));
},
},
watch: {
projectId: {
@@ -242,6 +335,7 @@ export default {
this.selectedModelCodes = [];
this.$emit("selection-change", this.selectedModelCodes);
this.pageNum = 1;
this.loadExternalCards();
this.fetchPeopleList();
},
},
@@ -253,13 +347,31 @@ export default {
isModelSelected(modelCode) {
return this.selectedModelCodes.includes(modelCode);
},
formatSourceLabel(item) {
return item && item.sourceType === "external" ? "外部" : "员工";
},
clearSelectedModels() {
this.selectedModelCodes = [];
this.$emit("selection-change", this.selectedModelCodes);
this.pageNum = 1;
this.fetchPeopleList({ syncCardLoading: true });
},
toggleModelSelection(modelCode) {
const target = this.displayCards.find((item) => item.key === modelCode);
const targetSourceType = target && target.sourceType === "external" ? "external" : "employee";
const currentSourceType = this.activeSourceType;
if (this.selectedModelCodes.length && targetSourceType !== currentSourceType) {
this.selectedModelCodes = [];
}
if (this.selectedModelCodes.includes(modelCode)) {
this.selectedModelCodes = this.selectedModelCodes.filter((item) => item !== modelCode);
} else {
this.selectedModelCodes = [...this.selectedModelCodes, modelCode];
}
this.$emit("selection-change", this.selectedModelCodes);
if (targetSourceType === "external") {
this.deptId = undefined;
}
this.pageNum = 1;
this.fetchPeopleList({ syncCardLoading: true });
},
@@ -284,15 +396,16 @@ export default {
this.pageNum = pageNum;
this.fetchPeopleList();
},
buildPeopleParams() {
buildPeopleParams(sourceType = this.activeSourceType, overrides = {}) {
return {
projectId: this.projectId,
modelCodes: this.selectedModelCodes,
matchMode: this.matchMode,
keyword: this.keyword,
deptId: this.deptId,
deptId: sourceType === "employee" && this.activeSourceType === "employee" ? this.deptId : undefined,
pageNum: this.pageNum,
pageSize: this.pageSize,
...overrides,
};
},
formatModelNames(modelNames) {
@@ -302,6 +415,10 @@ export default {
return modelNames.join("、");
},
handleViewProject(row) {
if ((row && row.sourceType === "external") || this.activeSourceType === "external") {
this.$emit("view-external-detail", row);
return;
}
this.$emit("view-project-analysis", row);
},
resolveModelTagType(tag) {
@@ -325,6 +442,44 @@ export default {
this.deptLoading = false;
}
},
async loadExternalCards() {
if (!this.projectId) {
this.externalCards = [];
return;
}
try {
const response = await getOverviewExternalRiskModelCards(this.projectId);
const rows = Array.isArray(response && response.data && response.data.cardList)
? response.data.cardList
: [];
this.externalCards = rows.map((item) => ({
key: item.modelCode,
title: item.modelName,
count: item.warningCount || 0,
peopleCount: item.peopleCount || 0,
sourceType: "external",
}));
} catch (error) {
this.externalCards = [];
console.error("加载外部人员风险模型失败", error);
}
},
handleModelExport() {
if (!this.projectId) {
return;
}
const url = this.activeSourceType === "external"
? "ccdi/project/overview/external-risk-models/people/export"
: "ccdi/project/overview/risk-models/people/export";
const filePrefix = this.activeSourceType === "external"
? "外部人员风险模型命中明细"
: "风险模型命中明细";
this.download(
url,
this.buildPeopleParams(),
`${filePrefix}_${this.projectId}_${new Date().getTime()}.xlsx`
);
},
async fetchPeopleList(options = {}) {
if (!this.projectId) {
this.peopleList = [];
@@ -338,9 +493,19 @@ export default {
}
this.tableLoading = true;
try {
const response = await getOverviewRiskModelPeople(this.buildPeopleParams());
if (this.activeSourceType === "all") {
await this.fetchAllPeopleList();
return;
}
const requestFn = this.activeSourceType === "external"
? getOverviewExternalRiskModelPeople
: getOverviewRiskModelPeople;
const response = await requestFn(this.buildPeopleParams());
const data = (response && response.data) || {};
this.peopleList = normalizePeopleRows(data.rows);
this.peopleList = normalizePeopleRows(
data.rows,
this.activeSourceType === "external" ? "external" : "employee"
);
this.total = Number(data.total || 0);
} catch (error) {
this.peopleList = [];
@@ -353,6 +518,55 @@ export default {
}
}
},
async fetchAllPeopleList() {
const countParams = {
modelCodes: [],
pageNum: 1,
pageSize: 1,
};
const [employeeCountRes, externalCountRes] = await Promise.all([
getOverviewRiskModelPeople(this.buildPeopleParams("employee", countParams)),
getOverviewExternalRiskModelPeople(this.buildPeopleParams("external", countParams)),
]);
const employeeTotal = Number((employeeCountRes && employeeCountRes.data && employeeCountRes.data.total) || 0);
const externalTotal = Number((externalCountRes && externalCountRes.data && externalCountRes.data.total) || 0);
const offset = (this.pageNum - 1) * this.pageSize;
const rows = [];
if (offset < employeeTotal) {
const employeeLimit = offset + this.pageSize;
const employeeRes = await getOverviewRiskModelPeople(this.buildPeopleParams("employee", {
modelCodes: [],
pageNum: 1,
pageSize: employeeLimit,
}));
const employeeRows = normalizePeopleRows(
employeeRes && employeeRes.data && employeeRes.data.rows,
"employee"
);
rows.push(...employeeRows.slice(offset, offset + this.pageSize));
}
if (rows.length < this.pageSize) {
const externalOffset = Math.max(0, offset - employeeTotal);
if (externalOffset < externalTotal) {
const externalLimit = externalOffset + (this.pageSize - rows.length);
const externalRes = await getOverviewExternalRiskModelPeople(this.buildPeopleParams("external", {
modelCodes: [],
pageNum: 1,
pageSize: externalLimit,
}));
const externalRows = normalizePeopleRows(
externalRes && externalRes.data && externalRes.data.rows,
"external"
);
rows.push(...externalRows.slice(externalOffset, externalOffset + (this.pageSize - rows.length)));
}
}
this.peopleList = rows;
this.total = employeeTotal + externalTotal;
},
},
};
</script>
@@ -363,8 +577,8 @@ export default {
}
.section-card {
padding: 20px;
border-radius: 14px;
padding: 18px;
border-radius: 8px;
background: #fff;
border: 1px solid var(--ccdi-border);
box-shadow: var(--ccdi-shadow);
@@ -389,40 +603,44 @@ export default {
color: var(--ccdi-text-muted);
}
.block + .block {
margin-top: 24px;
}
.block-header {
.model-workbench {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
flex-direction: column;
gap: 14px;
}
.block-title {
position: relative;
padding-left: 10px;
.model-list-panel,
.model-result-panel {
border: 1px solid var(--ccdi-border);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.model-result-panel {
min-width: 0;
padding-bottom: 12px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 54px;
padding: 12px 14px;
border-bottom: 1px solid #e7eef5;
background: #f8fbfe;
}
.panel-title {
font-size: 15px;
font-weight: 600;
line-height: 20px;
font-weight: 700;
color: var(--ccdi-text-primary);
}
.block-title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 4px;
height: 14px;
border-radius: 999px;
background: #8ea7be;
transform: translateY(-50%);
}
.block-subtitle {
margin-top: 4px;
padding-left: 10px;
.panel-subtitle {
margin-top: 3px;
font-size: 12px;
color: var(--ccdi-text-muted);
}
@@ -430,62 +648,159 @@ export default {
.model-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
gap: 10px;
padding: 12px;
}
.model-card {
display: block;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 104px;
width: 100%;
padding: 18px;
border: 1px solid var(--ccdi-border);
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
padding: 12px;
border: 1px solid #e4edf6;
border-radius: 6px;
background: #fff;
color: var(--ccdi-text-primary);
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.model-card:hover,
.model-card.is-active {
border-color: #bdd0e2;
box-shadow: 0 10px 22px rgba(47, 93, 138, 0.11);
transform: translateY(-2px);
background: #f3f8fd;
}
.model-card.is-active {
box-shadow: inset 0 3px 0 var(--ccdi-primary);
}
.model-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.source-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 20px;
border-radius: 999px;
background: #edf2f7;
color: #52677d;
font-size: 12px;
flex-shrink: 0;
}
.source-pill.is-external {
background: #edf7ff;
color: #2f5f91;
}
.model-card-title {
font-size: 14px;
font-weight: 600;
color: var(--ccdi-text-primary);
}
.model-card-count {
display: block;
margin-top: 12px;
font-size: 28px;
font-weight: 700;
color: var(--ccdi-primary);
color: var(--ccdi-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.model-card-meta {
margin-top: 8px;
font-size: 12px;
.model-card-metrics {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.model-card-metrics span {
display: inline-flex;
align-items: baseline;
gap: 4px;
color: var(--ccdi-text-muted);
}
.model-card-action {
margin-top: 12px;
font-size: 12px;
.model-card-metrics strong {
color: var(--ccdi-primary);
font-size: 22px;
line-height: 1;
}
.model-card-metrics em {
color: var(--ccdi-text-muted);
font-size: 12px;
font-style: normal;
}
.model-card-action {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
min-width: 44px;
padding: 0 8px;
border-radius: 4px;
color: var(--ccdi-primary);
font-size: 12px;
background: #eef6ff;
}
.model-card:not(.is-active) .model-card-action {
background: transparent;
}
.model-card.is-active .model-card-action {
font-weight: 600;
}
.selected-strip {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 14px 0;
padding: 8px 10px;
border: 1px solid #e6edf5;
border-radius: 6px;
background: #fbfdff;
}
.selected-label {
color: var(--ccdi-text-secondary);
font-size: 12px;
flex-shrink: 0;
}
.selected-content {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1;
}
.selected-placeholder {
color: var(--ccdi-text-muted);
font-size: 13px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
padding: 14px 16px;
border: 1px solid var(--ccdi-border);
border-radius: 12px;
background: #f8fbfe;
flex-wrap: wrap;
gap: 10px 14px;
margin: 10px 14px 12px;
padding: 10px;
border: 1px solid #e6edf5;
border-radius: 6px;
background: #fff;
}
.filter-item {
@@ -500,12 +815,12 @@ export default {
.filter-item--keyword,
.filter-item--dept {
min-width: 240px;
min-width: 220px;
}
.filter-item--keyword :deep(.el-input),
.filter-item--dept :deep(.el-select) {
width: 220px;
width: 200px;
}
.filter-label,
@@ -516,23 +831,6 @@ export default {
white-space: nowrap;
}
.filter-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.summary-value {
min-width: 0;
color: var(--ccdi-text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-actions {
margin-left: auto;
display: flex;
@@ -540,7 +838,10 @@ export default {
}
.model-table {
border-radius: 12px;
margin: 0 14px;
width: calc(100% - 28px);
border: 1px solid #e7eef5;
border-radius: 6px;
overflow: hidden;
}
@@ -562,12 +863,12 @@ export default {
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
margin: 12px 14px 0;
}
@media (max-width: 1200px) {
.model-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -575,5 +876,17 @@ export default {
.model-card-grid {
grid-template-columns: 1fr;
}
.filter-item--keyword,
.filter-item--dept,
.filter-item--keyword :deep(.el-input),
.filter-item--dept :deep(.el-select) {
width: 100%;
}
.filter-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -1,10 +1,18 @@
<template>
<section class="risk-people-section">
<div class="section-toolbar">
<el-button size="mini" type="text" @click="handleRiskPeopleExport">导出</el-button>
<el-tabs v-model="activeTab" class="people-tabs" @tab-click="handleTabChange">
<el-tab-pane label="员工风险人员" name="employee" />
<el-tab-pane v-if="hasExternalWarnings" :label="externalTabLabel" name="external" />
</el-tabs>
</div>
<el-table v-loading="tableLoading" :data="overviewList" class="people-table">
<el-table
v-if="activeTab === 'employee'"
v-loading="tableLoading"
:data="overviewList"
class="people-table"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="idNo" label="身份证号" min-width="180" />
@@ -46,11 +54,69 @@
</template>
</el-table-column>
</el-table>
<el-table
v-else
v-loading="externalLoading"
:data="externalRows"
class="people-table"
>
<template slot="empty">
<el-empty :image-size="80" description="当前项目暂无外部人员预警" />
</template>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="idNo" label="证件号" min-width="180" />
<el-table-column prop="subjectType" label="主体类型" min-width="110">
<template slot-scope="scope">
<el-tag size="mini" effect="plain">{{ scope.row.subjectType || "外部人员" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="riskLevel" label="风险等级" min-width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.riskLevelType" effect="plain">
{{ scope.row.riskLevel }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="核心异常点" min-width="260">
<template slot-scope="scope">
<div
v-if="scope.row.riskPointTagList && scope.row.riskPointTagList.length"
class="risk-point-tag-list"
>
<el-tag
v-for="(tag, index) in scope.row.riskPointTagList"
:key="`${scope.row.idNo || scope.row.name || index}-external-risk-point-${index}`"
class="core-risk-tag"
:style="resolveModelTagStyle(tag)"
size="mini"
effect="plain"
>
{{ tag.ruleName }}
</el-tag>
</div>
<span v-else class="empty-text">{{ scope.row.riskPoint || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="relatedObject" label="涉及对象" min-width="140" />
<el-table-column label="最近交易时间" min-width="170">
<template slot-scope="scope">
<span>{{ formatLatestTradeTime(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewExternal(scope.row)">
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="pageNum"
:limit.sync="pageSize"
v-show="currentTotal > 0"
:total="currentTotal"
:page.sync="currentPageNum"
:limit.sync="currentPageSize"
:page-sizes="[5]"
layout="total, prev, pager, next, jumper"
@pagination="handlePageChange"
@@ -59,7 +125,10 @@
</template>
<script>
import { getOverviewRiskPeople } from "@/api/ccdi/projectOverview";
import {
getOverviewExternalPersons,
getOverviewRiskPeople,
} from "@/api/ccdi/projectOverview";
// 历史静态回归锚点scope.row.actionLabel || "查看详情"
const CORE_TAG_PALETTE = {
@@ -157,10 +226,13 @@ function normalizeOverviewRows(rows) {
return [];
}
return rows.map((item) => ({
...item,
riskPointTagList: normalizeRiskPointTags(item.riskPointTagList, item.riskPoint, item.riskLevelType),
}));
return rows.map((item) => {
const riskPointTagList = normalizeRiskPointTags(item.riskPointTagList, item.riskPoint, item.riskLevelType);
return {
...item,
riskPointTagList,
};
});
}
export default {
@@ -182,12 +254,51 @@ export default {
total: 0,
tableLoading: false,
localRows: [],
activeTab: "employee",
externalRows: [],
externalTotal: 0,
externalPageNum: 1,
externalPageSize: 5,
externalLoading: false,
};
},
computed: {
overviewList() {
return this.localRows;
},
externalTabLabel() {
return `外部人员预警(${this.externalTotal})`;
},
hasExternalWarnings() {
return this.externalTotal > 0;
},
currentTotal() {
return this.activeTab === "external" ? this.externalTotal : this.total;
},
currentPageNum: {
get() {
return this.activeTab === "external" ? this.externalPageNum : this.pageNum;
},
set(value) {
if (this.activeTab === "external") {
this.externalPageNum = value;
return;
}
this.pageNum = value;
},
},
currentPageSize: {
get() {
return this.activeTab === "external" ? this.externalPageSize : this.pageSize;
},
set(value) {
if (this.activeTab === "external") {
this.externalPageSize = value;
return;
}
this.pageSize = value;
},
},
},
watch: {
sectionData: {
@@ -198,6 +309,7 @@ export default {
this.total = (this.sectionData && this.sectionData.total) || 0;
this.pageNum = (this.sectionData && this.sectionData.pageNum) || 1;
this.pageSize = (this.sectionData && this.sectionData.pageSize) || 5;
this.loadExternalPage(1);
},
},
},
@@ -205,22 +317,33 @@ export default {
resolveModelTagStyle(tag) {
return CORE_TAG_PALETTE[tag.modelCode] || {};
},
handleRiskPeopleExport() {
if (!this.projectId) {
return;
}
this.download(
"ccdi/project/overview/risk-people/export",
{
projectId: this.projectId,
},
`风险人员总览_${this.projectId}_${new Date().getTime()}.xlsx`
);
},
handleViewProject(row) {
this.$emit("view-project-analysis", row);
},
handleViewExternal(row) {
this.$emit("view-external-detail", row);
},
formatLatestTradeTime(row) {
const value = row && row.latestTradeTime ? String(row.latestTradeTime) : "";
if (!value || value === row.actionLabel) {
return "-";
}
return value;
},
handleTabChange() {
if (this.activeTab === "external" && !this.hasExternalWarnings) {
this.activeTab = "employee";
}
this.emitScopeSummary();
if (this.activeTab === "external" && !this.externalRows.length) {
this.loadExternalPage(1);
}
},
handlePageChange({ page }) {
if (this.activeTab === "external") {
this.loadExternalPage(page);
return;
}
this.loadRiskPeoplePage(page);
},
async loadRiskPeoplePage(pageNum) {
@@ -243,6 +366,44 @@ export default {
this.tableLoading = false;
}
},
async loadExternalPage(pageNum) {
if (!this.projectId) {
this.externalRows = [];
this.externalTotal = 0;
return;
}
this.externalLoading = true;
try {
const response = await getOverviewExternalPersons({
projectId: this.projectId,
pageNum,
pageSize: 5,
});
const data = (response && response.data) || {};
this.externalRows = normalizeOverviewRows(data.rows);
this.externalTotal = data.total || 0;
this.externalPageNum = data.pageNum || pageNum;
this.externalPageSize = data.pageSize || 5;
if (!this.hasExternalWarnings && this.activeTab === "external") {
this.activeTab = "employee";
}
this.emitScopeSummary();
} finally {
this.externalLoading = false;
}
},
emitScopeSummary() {
this.$emit("scope-summary-change", {
activeTab: this.activeTab,
employee: {
total: this.total,
},
external: {
total: this.externalTotal,
rows: this.externalRows,
},
});
},
},
};
</script>
@@ -256,11 +417,19 @@ export default {
.section-toolbar {
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.people-tabs {
flex: 1;
:deep(.el-tabs__header) {
margin: 0;
}
}
.people-table {
border-radius: 12px;
overflow: hidden;

View File

@@ -8,7 +8,7 @@ export const mockOverviewData = {
{ key: "riskPeople", label: "高风险", value: 10, icon: "el-icon-warning-outline", tone: "red" },
{ key: "medium", label: "中风险", value: 20, icon: "el-icon-s-opportunity", tone: "amber" },
{ key: "low", label: "低风险", value: 38, icon: "el-icon-data-line", tone: "green" },
{ key: "count", label: "无预警人数", value: 432, icon: "el-icon-document", tone: "blue" },
{ key: "count", label: "无风险", value: 432, icon: "el-icon-s-data", tone: "blue" },
],
},
riskPeople: {
@@ -433,6 +433,7 @@ export function createOverviewLoadedData({
riskModelCardsData,
suspiciousData,
creditNegativeData,
externalRiskSummary,
} = {}) {
return {
...mockOverviewData,
@@ -441,6 +442,7 @@ export function createOverviewLoadedData({
...(dashboardData || {}),
actions: mockOverviewData.summary.actions,
stats: normalizeSummaryStats(dashboardData && dashboardData.stats),
externalRiskSummary: externalRiskSummary || {},
},
riskPeople: {
...mockOverviewData.riskPeople,

View File

@@ -69,6 +69,8 @@
@generate-report="handleGenerateReport"
@fetch-bank-info="handleFetchBankInfo"
@evidence-confirm="handleEvidenceConfirm"
@open-detail-query="handleOpenDetailQuery"
:detail-query-prefill="detailQueryPrefill"
/>
<evidence-confirm-dialog
@@ -130,6 +132,7 @@ export default {
evidenceConfirmVisible: false,
evidenceDrawerVisible: false,
evidencePayload: {},
detailQueryPrefill: null,
projectStatusPollingTimer: null,
projectStatusPollingInterval: 1000,
projectStatusPollingLoading: false,
@@ -397,6 +400,14 @@ export default {
// 直接触发菜单选择
this.handleMenuSelect(route);
},
handleOpenDetailQuery(payload = {}) {
this.detailQueryPrefill = {
...payload,
nonce: Date.now(),
};
this.setActiveTab("detail");
this.syncRouteTab("detail");
},
/** UploadData 组件:数据上传完成 */
handleDataUploaded({ type }) {
console.log("数据上传完成:", type);

View File

@@ -33,6 +33,7 @@ module.exports = {
host: '0.0.0.0',
port: port,
open: true,
historyApiFallback: true,
proxy: {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {

View File

@@ -0,0 +1,131 @@
SET NAMES utf8mb4;
SET collation_connection = utf8mb4_general_ci;
SET @project_id := 90624001;
SET @staff_id_card := '330100199001010011';
SET @family_id_card := '330100199201010022';
SET @intermediary_id_card := '330100198801010033';
SET @customer_id_card := '330100198901010044';
DELETE FROM ccdi_bank_statement_tag_result WHERE project_id = @project_id;
DELETE FROM ccdi_bank_statement WHERE project_id = @project_id;
DELETE FROM ccdi_project_overview_employee_result WHERE project_id = @project_id;
DELETE FROM ccdi_staff_fmy_relation WHERE person_id = @staff_id_card OR relation_cert_no = @family_id_card;
DELETE FROM ccdi_base_staff WHERE id_card = @staff_id_card;
DELETE FROM ccdi_biz_intermediary WHERE person_id = @intermediary_id_card;
DELETE FROM ccdi_credit_customer_base WHERE person_id = @customer_id_card;
DELETE FROM ccdi_project WHERE project_id = @project_id;
INSERT INTO ccdi_project (
project_id, project_name, description, config_type, status, is_archived,
target_count, high_risk_count, medium_risk_count, low_risk_count,
del_flag, create_by, create_time, update_by, update_time, remark
) VALUES (
@project_id, '外部人员预警联调项目', '用于验证中介、客户等外部人员流水进入结果总览',
'default', '0', 0, 1, 1, 0, 0, '0', 'admin', NOW(), 'admin', NOW(), '外部人员预警测试数据'
);
INSERT INTO ccdi_base_staff (
staff_id, name, dept_id, id_card, phone, annual_income, hire_date,
is_party_member, status, create_by, create_time, update_by, update_time
) VALUES (
9062401, '测试员工张三', 100, @staff_id_card, '13800000001', 180000.00,
'2020-01-01', 1, '0', 'admin', NOW(), 'admin', NOW()
);
INSERT INTO ccdi_staff_fmy_relation (
person_id, relation_type, relation_name, gender, birth_date,
relation_cert_type, relation_cert_no, mobile_phone1, annual_income,
contact_address, relation_desc, status, effective_date, remark,
data_source, is_emp_family, is_cust_family, created_by, updated_by, create_time, update_time
) VALUES (
@staff_id_card, '配偶', '测试亲属李四', 'F', '1992-01-01',
'身份证', @family_id_card, '13800000002', 120000.00,
'杭州市测试地址', '测试员工配偶', 1, NOW(), '外部人员预警测试数据',
'MANUAL', 1, 0, 'admin', 'admin', NOW(), NOW()
);
INSERT INTO ccdi_biz_intermediary (
biz_id, person_type, person_sub_type, name, gender, id_type, person_id,
mobile, company, data_source, remark, created_by, updated_by, create_time, update_time
) VALUES (
'EXTWARN-MID-001', '个人', '本人', '测试中介王某', 'M', '身份证', @intermediary_id_card,
'13800000003', '测试中介服务部', 'MANUAL', '外部人员预警测试数据', 'admin', 'admin', NOW(), NOW()
);
INSERT INTO ccdi_credit_customer_base (
person_id, name, cinocsno, idno_type, source_type, create_by, create_time, update_by, update_time
) VALUES (
@customer_id_card, '测试客户赵某', 'CUST-EXTWARN-001', '个人', 'MANUAL', 'admin', NOW(), 'admin', NOW()
);
INSERT INTO ccdi_bank_statement (
bank_statement_id, project_id, LE_ID, ACCOUNT_ID, LE_ACCOUNT_NAME, LE_ACCOUNT_NO,
ACCOUNTING_DATE_ID, ACCOUNTING_DATE, TRX_DATE, CURRENCY, AMOUNT_DR, AMOUNT_CR,
AMOUNT_BALANCE, CASH_TYPE, CUSTOMER_LE_ID, CUSTOMER_ACCOUNT_NAME, CUSTOMER_ACCOUNT_NO,
customer_bank, customer_reference, customer_cert_no, customer_social_credit_code,
USER_MEMO, BANK_COMMENTS, BANK_TRX_NUMBER, BANK, TRX_FLAG, TRX_TYPE, EXCEPTION_TYPE,
internal_flag, batch_id, batch_sequence, CREATE_DATE, created_by, meta_json, no_balance,
begin_balance, end_balance, group_id, override_bs_id, payment_method, cret_no
) VALUES
(
9062400101, @project_id, 0, 0, '测试中介王某', '6222000000000033',
20260601, '2026-06-01', '2026-06-01 09:30:00', 'CNY', 180000.00, 0.00,
820000.00, '转账', -1, '测试亲属李四', '6222000000000022',
'杭州银行', '', @family_id_card, NULL,
'咨询服务费', '转账汇款', 'EXTWARN001', 'HZB', '0', 0, '',
0, 90624001, 1, NOW(), 1, NULL, 0,
0, 0, 0, 0, '网银', @intermediary_id_card
),
(
9062400102, @project_id, 0, 0, '测试中介王某', '6222000000000033',
20260603, '2026-06-03', '2026-06-03 22:18:00', 'CNY', 0.00, 52000.00,
872000.00, '转账', -1, '测试员工张三', '6222000000000011',
'杭州银行', '', @staff_id_card, NULL,
'夜间转账', '转账汇款', 'EXTWARN002', 'HZB', '0', 0, '',
0, 90624001, 2, NOW(), 1, NULL, 0,
0, 0, 0, 0, '网银', @intermediary_id_card
),
(
9062400103, @project_id, 0, 0, '测试客户赵某', '6222000000000044',
20260604, '2026-06-04', '2026-06-04 21:40:00', 'CNY', 0.00, 8800.00,
108800.00, '转账', -1, '测试中介王某', '6222000000000033',
'杭州银行', '', @intermediary_id_card, NULL,
'牌局结算', '转账汇款', 'EXTWARN003', 'HZB', '0', 0, '',
0, 90624001, 3, NOW(), 1, NULL, 0,
0, 0, 0, 0, '网银', @customer_id_card
);
INSERT INTO ccdi_bank_statement_tag_result (
project_id, model_code, model_name, rule_code, rule_name, indicator_code,
result_type, risk_level, bank_statement_id, object_type, object_key,
group_id, log_id, reason_detail, business_caliber_snapshot, hit_value_snapshot,
create_by, create_time, update_by, update_time, remark
) VALUES
(
@project_id, 'EXTERNAL_LARGE_TRANSACTION', '外部人员大额交易',
'EXTERNAL_SINGLE_LARGE_AMOUNT', '外部人员单笔大额交易', 'EXTERNAL_SINGLE_AMOUNT',
'STATEMENT', 'HIGH', 9062400101, 'EXTERNAL_CERT_NO', @intermediary_id_card,
0, 90624001, '测试中介王某单笔转出180000元至员工亲属', '外部人员流水命中大额交易', '180000',
'admin', NOW(), 'admin', NOW(), '外部人员预警测试数据'
),
(
@project_id, 'EXTERNAL_SUSPICIOUS_RELATION', '外部人员可疑关系',
'EXTERNAL_TO_STAFF_FAMILY_TRANSACTION', '外部人员与员工亲属交易', 'EXTERNAL_RELATION',
'STATEMENT', 'HIGH', 9062400101, 'EXTERNAL_CERT_NO', @intermediary_id_card,
0, 90624001, '测试中介王某与测试员工张三配偶发生资金往来', '外部人员与员工亲属交易', '配偶',
'admin', NOW(), 'admin', NOW(), '外部人员预警测试数据'
),
(
@project_id, 'EXTERNAL_ABNORMAL_TRANSACTION', '外部人员异常交易',
'EXTERNAL_NIGHT_TRANSACTION', '外部人员夜间集中交易', 'EXTERNAL_NIGHT_TIME',
'STATEMENT', 'MEDIUM', 9062400102, 'EXTERNAL_CERT_NO', @intermediary_id_card,
0, 90624001, '测试中介王某夜间与员工发生资金往来', '外部人员夜间异常交易', '22:18',
'admin', NOW(), 'admin', NOW(), '外部人员预警测试数据'
),
(
@project_id, 'EXTERNAL_SUSPICIOUS_GAMBLING', '外部人员可疑赌博',
'EXTERNAL_GAMBLING_MEMO', '外部人员疑似赌博摘要', 'EXTERNAL_MEMO',
'STATEMENT', 'MEDIUM', 9062400103, 'EXTERNAL_CERT_NO', @customer_id_card,
0, 90624001, '测试客户赵某交易摘要含牌局结算', '外部人员可疑赌博交易', '牌局结算',
'admin', NOW(), 'admin', NOW(), '外部人员预警测试数据'
);

View File

@@ -0,0 +1,66 @@
START TRANSACTION;
INSERT INTO ccdi_bank_tag_rule (
model_code,
model_name,
rule_code,
rule_name,
indicator_code,
result_type,
risk_level,
business_caliber,
enabled,
sort_order,
create_by,
remark
) VALUES
('EXTERNAL_LARGE_TRANSACTION', '外部人员大额交易',
'EXTERNAL_SINGLE_LARGE_AMOUNT', '外部人员单笔大额交易', 'FREQUENT_TRANSFER',
'STATEMENT', 'MEDIUM',
'本方证件号非空且未命中员工、员工亲属的外部主体,单笔交易金额超过大额转账阈值。',
1, 10, 'system', '真实规则:识别外部人员单笔大额交易'),
('EXTERNAL_LARGE_TRANSACTION', '外部人员大额交易',
'EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT', '外部人员累计交易超限', 'CUMULATIVE_TRANSACTION_AMOUNT',
'OBJECT', 'MEDIUM',
'本方证件号非空且未命中员工、员工亲属的外部主体,累计交易金额超过累计大额收入阈值。',
1, 20, 'system', '真实规则:识别外部人员累计交易金额超限'),
('EXTERNAL_LARGE_TRANSACTION', '外部人员大额交易',
'EXTERNAL_ANNUAL_TURNOVER', '外部人员年流水交易额超限', 'ANNUAL_TURNOVER',
'OBJECT', 'MEDIUM',
'本方证件号非空且未命中员工、员工亲属的外部主体,近一年流水交易额超过年累计交易额阈值。',
1, 30, 'system', '真实规则:识别外部人员年流水交易额超限'),
('EXTERNAL_ABNORMAL_TRANSACTION', '外部人员异常交易',
'EXTERNAL_NIGHT_TRANSACTION', '外部人员夜间集中交易', NULL,
'STATEMENT', 'MEDIUM',
'本方证件号非空且未命中员工、员工亲属的外部主体在22:00至次日06:00发生资金交易。',
1, 40, 'system', '真实规则:识别外部人员夜间交易'),
('EXTERNAL_SUSPICIOUS_GAMBLING', '外部人员可疑赌博',
'EXTERNAL_GAMBLING_MEMO', '外部人员疑似赌博摘要', NULL,
'STATEMENT', 'MEDIUM',
'本方证件号非空且未命中员工、员工亲属的外部主体,交易摘要、交易类型或对手方名称命中赌博敏感词。',
1, 50, 'system', '真实规则:识别外部人员疑似赌博摘要'),
('EXTERNAL_SUSPICIOUS_GAMBLING', '外部人员可疑赌博',
'EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER', '外部人员同日多对手方疑似赌博交易', NULL,
'OBJECT', 'HIGH',
'本方证件号非空且未命中员工、员工亲属的外部主体,同日多笔、多对手方交易且金额落在疑似赌博区间。',
1, 60, 'system', '真实规则:识别外部人员同日多对手方疑似赌博交易'),
('EXTERNAL_SUSPICIOUS_RELATION', '外部人员可疑关系',
'EXTERNAL_TO_STAFF_FAMILY_TRANSACTION', '外部人员与员工或员工亲属交易', NULL,
'STATEMENT', 'HIGH',
'本方证件号非空且未命中员工、员工亲属的外部主体,对手方账号命中员工或员工亲属,或对手方账号未命中已维护账号时对手方名称命中员工或员工亲属。',
1, 70, 'system', '真实规则:识别外部人员与员工或员工亲属资金往来')
ON DUPLICATE KEY UPDATE
model_code = VALUES(model_code),
model_name = VALUES(model_name),
rule_name = VALUES(rule_name),
indicator_code = VALUES(indicator_code),
result_type = VALUES(result_type),
risk_level = VALUES(risk_level),
business_caliber = VALUES(business_caliber),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
update_by = 'system',
update_time = NOW(),
remark = VALUES(remark);
COMMIT;