Compare commits

7 Commits

80 changed files with 9591 additions and 241 deletions

View File

@@ -0,0 +1,78 @@
package com.ruoyi.ccdi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.utils.SecurityUtils;
import java.util.List;
/**
* 资金流图谱Controller
*/
@RestController
@RequestMapping("/ccdi/project/fund-graph")
@Tag(name = "资金流图谱")
public class CcdiFundGraphController extends BaseController {
@Resource
private ICcdiFundGraphService fundGraphService;
@GetMapping("/search")
@Operation(summary = "查询资金流图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
List<CcdiFundGraphNodeVO> subjects = fundGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@GetMapping("/graph")
@Operation(summary = "查询一层资金流图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphVO graph = fundGraphService.getFundGraph(queryDTO);
return AjaxResult.success(graph);
}
@GetMapping("/edge-detail")
@Operation(summary = "查询资金边流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public TableDataInfo getEdgeDetail(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiFundGraphStatementVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiFundGraphStatementVO> result = fundGraphService.getEdgeDetails(page, queryDTO);
return getDataTable(result.getRecords(), result.getTotal());
}
@PostMapping("/manual-edge")
@Operation(summary = "新增手工资金流向")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult saveManualEdge(@RequestBody CcdiFundGraphManualEdgeSaveDTO saveDTO) {
try {
CcdiFundGraphEdgeVO edge = fundGraphService.saveManualEdge(saveDTO, SecurityUtils.getUsername());
return AjaxResult.success(edge);
} catch (IllegalArgumentException e) {
return AjaxResult.error(e.getMessage());
}
}
}

View File

@@ -184,10 +184,10 @@ public class CcdiProjectOverviewController extends BaseController {
} }
/** /**
* 一键导出结果总览报告 * 导出结果总览报告
*/ */
@RequestMapping(value = "/report/export", method = { RequestMethod.GET, RequestMethod.POST }) @RequestMapping(value = "/report/export", method = { RequestMethod.GET, RequestMethod.POST })
@Operation(summary = "一键导出结果总览报告") @Operation(summary = "导出结果总览报告")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')") @PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportOverviewReport(HttpServletResponse response, Long projectId) { public void exportOverviewReport(HttpServletResponse response, Long projectId) {
overviewService.exportOverviewReport(response, projectId); overviewService.exportOverviewReport(response, projectId);

View File

@@ -0,0 +1,55 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 关系图谱Controller
*/
@RestController
@RequestMapping("/ccdi/project/relation-graph")
@Tag(name = "关系图谱")
public class CcdiRelationGraphController extends BaseController {
@Resource
private ICcdiRelationGraphService relationGraphService;
@GetMapping("/search")
@Operation(summary = "查询关系图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
List<CcdiRelationGraphNodeVO> subjects = relationGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@GetMapping("/graph")
@Operation(summary = "查询一层关系图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphVO graph = relationGraphService.getRelationGraph(queryDTO);
return AjaxResult.success(graph);
}
@GetMapping("/suspected-enterprises")
@Operation(summary = "查询关系图谱疑似同名企业")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseVO result = relationGraphService.getSuspectedEnterprises(queryDTO);
return AjaxResult.success(result);
}
}

View File

@@ -0,0 +1,42 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱边明细查询条件
*/
@Data
public class CcdiFundGraphEdgeDetailQueryDTO {
/** 项目ID历史字段资金流图谱不按项目过滤 */
private Long projectId;
/** 身份证号、员工姓名或本方户名 */
private String keyword;
/** 主体节点object_key复用图谱公共SQL片段时兼容条件判断 */
private String objectKey;
/** 边起点 */
private String fromKey;
/** 边终点 */
private String toKey;
/** 方向1支出2收入 */
private String direction;
/** 交易开始时间 */
private String transactionStartTime;
/** 交易结束时间 */
private String transactionEndTime;
/** 最小金额 */
private BigDecimal amountMin;
/** 最大金额 */
private BigDecimal amountMax;
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 手工资金流向保存参数。
*/
@Data
public class CcdiFundGraphManualEdgeSaveDTO {
/** 起点主体object_key为空时默认使用当前查询中心 */
private String fromObjectKey;
/** 起点主体名称 */
private String fromName;
/** 终点主体object_key已有节点时传入 */
private String toObjectKey;
/** 终点主体名称;新建主体时必填 */
private String toName;
/** 终点主体身份证号/证件号有值时按md5(trim(idNo))复用主体 */
private String toIdNo;
/** 手工录入汇总金额 */
private BigDecimal amount;
/** 手工录入笔数 */
private Integer transactionCount;
/** 方向1支出2收入 */
private String direction;
/** 资金流向关系说明 */
private String relationDesc;
/** 来源说明 */
private String sourceDesc;
/** 分析备注 */
private String remark;
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱查询条件
*/
@Data
public class CcdiFundGraphQueryDTO {
/** 项目ID历史字段资金流图谱不按项目过滤 */
private Long projectId;
/** 身份证号、员工姓名或本方户名 */
private String keyword;
/** 主体节点object_key节点穿透时直接使用 */
private String objectKey;
/** 交易开始时间 */
private String transactionStartTime;
/** 交易结束时间 */
private String transactionEndTime;
/** 最小金额 */
private BigDecimal amountMin;
/** 最大金额 */
private BigDecimal amountMax;
/** 最小汇总金额默认1000 */
private BigDecimal minTotalAmount;
/** 方向1支出2收入 */
private String direction;
/** 返回边数量上限 */
private Integer limit;
/** 预留追溯层级,一期固定按一层处理 */
private Integer depth;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 关系图谱查询条件
*/
@Data
public class CcdiRelationGraphQueryDTO {
/** 项目ID历史字段关系图谱不按项目过滤 */
private Long projectId;
/** 身份证号、姓名、统一社会信用代码或节点object_key */
private String keyword;
/** 节点object_key节点穿透时直接使用 */
private String objectKey;
/** 返回边数量上限 */
private Integer limit;
/** 预留追溯层级,一期固定按一层处理 */
private Integer depth;
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 关系图谱疑似企业查询条件
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseQueryDTO {
/** 姓名 */
private String personName;
/** 证件号 */
private String certNo;
/** 出生日期yyyy-MM-dd */
private String birthDate;
/** 返回数量上限 */
private Integer limit;
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱汇总边
*/
@Data
public class CcdiFundGraphEdgeVO {
private String edgeKey;
private String fromKey;
private String toKey;
private String fromObjectKey;
private String toObjectKey;
private String fromName;
private String toName;
private BigDecimal totalAmount;
private Long transactionCount;
private String firstTrxDate;
private String lastTrxDate;
private String direction;
private String familyRelationType;
private String sourceType;
private String relationDesc;
private String sourceDesc;
private String remark;
private Integer depth;
private Boolean canTrace;
}

View File

@@ -0,0 +1,48 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱节点
*/
@Data
public class CcdiFundGraphNodeVO {
private String nodeKey;
private String objectKey;
private String nodeName;
private String idNo;
private String cinocsno;
private String idnoType;
private String staffId;
private String sourceType;
private String nodeType;
private String identityType;
private String relationType;
private Long accountCount;
private String createdTime;
private String updatedTime;
private Boolean canExpand;
private Integer depth;
private BigDecimal totalAmount;
private Long transactionCount;
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱边对应流水明细
*/
@Data
public class CcdiFundGraphStatementVO {
private Long bankStatementId;
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String cashType;
private String userMemo;
private BigDecimal amount;
private String direction;
private String familyRelationType;
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 资金流图谱结果
*/
@Data
public class CcdiFundGraphVO {
private CcdiFundGraphNodeVO centerNode;
private List<CcdiFundGraphNodeVO> nodes = new ArrayList<>();
private List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
private BigDecimal totalAmount = BigDecimal.ZERO;
private Long transactionCount = 0L;
private Integer maxDepth = 1;
private Boolean traceReserved = true;
}

View File

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

View File

@@ -0,0 +1,90 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 关系图谱边
*/
@Data
public class CcdiRelationGraphEdgeVO {
private String objectKey;
private String fromKey;
private String toKey;
private String fromObjectKey;
private String toObjectKey;
private String fromName;
private String toName;
private String edgeTable;
private String relationType;
private String companyName;
private String stockName;
private String stockType;
private String stockPercent;
private String shouldCapi;
private String shouldCapiValue;
private String shouldCapiUnit;
private String shoudDate;
private String pKeyNo;
private String operName;
private String operKeyNo;
private String personId;
private String relationName;
private String relationCertNo;
private String gender;
private String birthDate;
private String relationCertType;
private String mobilePhone1;
private String mobilePhone2;
private String wechatNo1;
private String wechatNo2;
private String wechatNo3;
private String contactAddress;
private BigDecimal annualIncome;
private String relationDesc;
private String status;
private String effectiveDate;
private String invalidDate;
private String remark;
private String dataSource;
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 关系图谱节点
*/
@Data
public class CcdiRelationGraphNodeVO {
private String objectKey;
private String nodeKey;
private String nodeName;
private String idNumber;
private String subjectType;
private String sourceType;
private String detailRefType;
private String detailRefKey;
private String createdTime;
private String updatedTime;
private Boolean canExpand;
private Integer depth;
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 关系图谱疑似企业明细
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseItemVO {
private String candidateKeyNo;
private String personName;
private String companyId;
private String companyName;
private String creditCode;
private String enterpriseStatus;
private String industryName;
private String relationType;
private String stockPercent;
/** 企业成立日期或当前可用的工商关系日期 */
private String establishDate;
private Integer ageAtEstablish;
private String matchReason;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 关系图谱疑似企业结果
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseVO {
/** 是否因同名候选过多被拦截 */
private Boolean blocked = false;
/** 拦截或空结果说明 */
private String message;
/** 同名工商keyno数量 */
private Integer sameNameKeyNoCount = 0;
/** 表格明细 */
private List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows = new ArrayList<>();
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 关系图谱结果
*/
@Data
public class CcdiRelationGraphVO {
private CcdiRelationGraphNodeVO centerNode;
private List<CcdiRelationGraphNodeVO> nodes = new ArrayList<>();
private List<CcdiRelationGraphEdgeVO> edges = new ArrayList<>();
private Long edgeCount = 0L;
private Integer maxDepth = 1;
}

View File

@@ -272,9 +272,11 @@ public interface CcdiBankTagAnalysisMapper {
* 微信支付宝提现超额 * 微信支付宝提现超额
* *
* @param projectId 项目ID * @param projectId 项目ID
* @param amountThreshold 提现金额阈值
* @return 对象命中结果 * @return 对象命中结果
*/ */
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId); List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId,
@Param("amountThreshold") BigDecimal amountThreshold);
/** /**
* 工资快速转出 * 工资快速转出

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 资金流图谱Mapper
*/
@Mapper
public interface CcdiFundGraphMapper {
List<CcdiFundGraphNodeVO> selectFundGraphSubjects(@Param("query") CcdiFundGraphQueryDTO query);
List<CcdiFundGraphEdgeVO> selectFundGraphEdges(@Param("query") CcdiFundGraphQueryDTO query);
List<CcdiFundGraphEdgeVO> selectFundGraphManualEdges(@Param("query") CcdiFundGraphQueryDTO query);
int countSubjectByObjectKey(@Param("objectKey") String objectKey);
int insertManualSubject(
@Param("objectKey") String objectKey,
@Param("idNo") String idNo,
@Param("name") String name
);
int insertManualEdge(
@Param("objectKey") String objectKey,
@Param("dto") CcdiFundGraphManualEdgeSaveDTO dto,
@Param("operator") String operator
);
Page<CcdiFundGraphStatementVO> selectFundGraphEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
@Param("query") CcdiFundGraphEdgeDetailQueryDTO query
);
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.ccdi.project.mapper;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 关系图谱Mapper
*/
@Mapper
public interface CcdiRelationGraphMapper {
List<CcdiRelationGraphNodeVO> selectRelationGraphSubjects(@Param("query") CcdiRelationGraphQueryDTO query);
List<CcdiRelationGraphNodeVO> selectRelationGraphNodesByKeys(@Param("objectKeys") List<String> objectKeys);
List<CcdiRelationGraphEdgeVO> selectRelationGraphEdges(@Param("query") CcdiRelationGraphQueryDTO query);
int countSuspectedEnterpriseKeyNos(@Param("personName") String personName);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> selectSuspectedEnterprises(@Param("personName") String personName,
@Param("limit") Integer limit);
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.ccdi.project.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import java.util.List;
/**
* 资金流图谱Service接口
*/
public interface ICcdiFundGraphService {
List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO);
CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO);
Page<CcdiFundGraphStatementVO> getEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
CcdiFundGraphEdgeDetailQueryDTO queryDTO
);
CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator);
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import java.util.List;
/**
* 关系图谱Service接口
*/
public interface ICcdiRelationGraphService {
List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO);
CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO);
CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO);
}

View File

@@ -34,6 +34,7 @@ public class BankTagRuleConfigResolver {
Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")), Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")),
Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")), Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")), Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")),
Map.entry("WITHDRAW_AMT", Set.of("WITHDRAW_AMT")),
Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")), Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")),
Map.entry("LARGE_STOCK_TRADING", 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("MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),

View File

@@ -285,7 +285,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects( case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(
projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT")) projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT"))
); );
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId); case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(
projectId, toBigDecimal(config.getThresholdValue("WITHDRAW_AMT"))
);
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId); case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId); case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);
case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId); case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId);

View File

@@ -0,0 +1,366 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import com.ruoyi.ccdi.project.mapper.CcdiFundGraphMapper;
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 资金流图谱Service实现
*/
@Service
public class CcdiFundGraphServiceImpl implements ICcdiFundGraphService {
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private static final BigDecimal DEFAULT_MIN_TOTAL_AMOUNT = new BigDecimal("1000");
private static final Comparator<CcdiFundGraphEdgeVO> EDGE_COMPARATOR = Comparator
.comparing(CcdiFundGraphServiceImpl::safeAmount, Comparator.reverseOrder())
.thenComparing(CcdiFundGraphServiceImpl::safeTransactionCount, Comparator.reverseOrder())
.thenComparing(CcdiFundGraphServiceImpl::safeDateText, Comparator.reverseOrder())
.thenComparing(edge -> normalizeSortText(edge == null ? null : edge.getEdgeKey()));
@Resource
private CcdiFundGraphMapper fundGraphMapper;
@Override
public List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
return Collections.emptyList();
}
return fundGraphMapper.selectFundGraphSubjects(query);
}
@Override
public CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
CcdiFundGraphNodeVO centerNode = resolveCenterNode(query);
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
return new CcdiFundGraphVO();
}
query.setObjectKey(centerNode.getObjectKey());
List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
List<CcdiFundGraphEdgeVO> realEdges = fundGraphMapper.selectFundGraphEdges(query);
if (realEdges != null) {
edges.addAll(realEdges);
}
List<CcdiFundGraphEdgeVO> manualEdges = fundGraphMapper.selectFundGraphManualEdges(query);
if (manualEdges != null) {
edges.addAll(manualEdges);
}
edges = sortAndLimitEdges(edges, query.getLimit());
CcdiFundGraphVO graph = new CcdiFundGraphVO();
graph.setCenterNode(centerNode);
graph.setEdges(edges);
graph.setNodes(buildNodes(centerNode, edges));
graph.setTransactionCount(edges.stream()
.map(CcdiFundGraphEdgeVO::getTransactionCount)
.filter(item -> item != null)
.reduce(0L, Long::sum));
graph.setTotalAmount(edges.stream()
.map(CcdiFundGraphEdgeVO::getTotalAmount)
.filter(item -> item != null)
.reduce(BigDecimal.ZERO, BigDecimal::add));
graph.setMaxDepth(1);
graph.setTraceReserved(true);
return graph;
}
@Override
public Page<CcdiFundGraphStatementVO> getEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
CcdiFundGraphEdgeDetailQueryDTO queryDTO
) {
CcdiFundGraphEdgeDetailQueryDTO query = normalizeDetailQuery(queryDTO);
if (isBlank(query.getFromKey()) || isBlank(query.getToKey())) {
return page;
}
return fundGraphMapper.selectFundGraphEdgeDetails(page, query);
}
@Override
public CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator) {
CcdiFundGraphManualEdgeSaveDTO dto = normalizeManualEdge(saveDTO);
String fromObjectKey = dto.getFromObjectKey();
String toObjectKey = resolveManualToObjectKey(dto);
dto.setToObjectKey(toObjectKey);
String edgeObjectKey = md5("MANUAL_EDGE|" + fromObjectKey + "|" + toObjectKey + "|" + dto.getDirection()
+ "|" + UUID.randomUUID());
fundGraphMapper.insertManualEdge(edgeObjectKey, dto, normalizeText(operator));
CcdiFundGraphEdgeVO edge = new CcdiFundGraphEdgeVO();
edge.setEdgeKey(edgeObjectKey);
edge.setFromKey(toSubjectKey(fromObjectKey));
edge.setToKey(toSubjectKey(toObjectKey));
edge.setFromObjectKey(fromObjectKey);
edge.setToObjectKey(toObjectKey);
edge.setFromName(dto.getFromName());
edge.setToName(dto.getToName());
edge.setTotalAmount(dto.getAmount());
edge.setTransactionCount(dto.getTransactionCount() == null ? 1L : dto.getTransactionCount().longValue());
edge.setDirection(dto.getDirection());
edge.setRelationDesc(dto.getRelationDesc());
edge.setSourceDesc(dto.getSourceDesc());
edge.setRemark(dto.getRemark());
edge.setSourceType("MANUAL");
edge.setDepth(1);
edge.setCanTrace(false);
return edge;
}
private CcdiFundGraphNodeVO resolveCenterNode(CcdiFundGraphQueryDTO query) {
if (isBlank(query.getObjectKey()) && isBlank(query.getKeyword())) {
return null;
}
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
if (subjects == null || subjects.isEmpty()) {
return null;
}
return subjects.get(0);
}
private List<CcdiFundGraphNodeVO> buildNodes(CcdiFundGraphNodeVO centerNode, List<CcdiFundGraphEdgeVO> edges) {
Map<String, CcdiFundGraphNodeVO> nodeMap = new LinkedHashMap<>();
Map<String, CcdiFundGraphNodeVO> subjectCache = new LinkedHashMap<>();
subjectCache.put(centerNode.getObjectKey(), centerNode);
addNode(nodeMap, centerNode, centerNode.getNodeKey(), centerNode.getObjectKey(), centerNode.getNodeName(),
centerNode.getRelationType(), centerNode.getCanExpand(), BigDecimal.ZERO, 0L);
for (CcdiFundGraphEdgeVO edge : edges) {
String centerObjectKey = centerNode.getObjectKey();
String fromRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getFromObjectKey())
? null
: edge.getFamilyRelationType();
String toRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getToObjectKey())
? null
: edge.getFamilyRelationType();
addNode(nodeMap, lookupSubject(edge.getFromObjectKey(), subjectCache), edge.getFromKey(),
edge.getFromObjectKey(), edge.getFromName(), fromRelationType, true, edge.getTotalAmount(),
edge.getTransactionCount());
addNode(nodeMap, lookupSubject(edge.getToObjectKey(), subjectCache), edge.getToKey(),
edge.getToObjectKey(), edge.getToName(), toRelationType, edge.getCanTrace(),
edge.getTotalAmount(), edge.getTransactionCount());
}
return List.copyOf(nodeMap.values());
}
private CcdiFundGraphNodeVO lookupSubject(String objectKey, Map<String, CcdiFundGraphNodeVO> subjectCache) {
if (isBlank(objectKey)) {
return null;
}
if (subjectCache.containsKey(objectKey)) {
return subjectCache.get(objectKey);
}
CcdiFundGraphQueryDTO query = new CcdiFundGraphQueryDTO();
query.setObjectKey(objectKey);
query.setLimit(DEFAULT_LIMIT);
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
CcdiFundGraphNodeVO subject = subjects == null || subjects.isEmpty() ? null : subjects.get(0);
subjectCache.put(objectKey, subject);
return subject;
}
private String resolveManualToObjectKey(CcdiFundGraphManualEdgeSaveDTO dto) {
if (!isBlank(dto.getToObjectKey())) {
ensureManualSubject(dto.getToObjectKey(), dto.getToIdNo(), dto.getToName());
return dto.getToObjectKey();
}
String objectKey = !isBlank(dto.getToIdNo())
? md5(dto.getToIdNo())
: md5("MANUAL_NODE|" + dto.getToName() + "|" + UUID.randomUUID());
dto.setToObjectKey(objectKey);
ensureManualSubject(objectKey, dto.getToIdNo(), dto.getToName());
return objectKey;
}
private void ensureManualSubject(String objectKey, String idNo, String name) {
if (fundGraphMapper.countSubjectByObjectKey(objectKey) > 0) {
return;
}
fundGraphMapper.insertManualSubject(objectKey, normalizeText(idNo), normalizeText(name));
}
private CcdiFundGraphManualEdgeSaveDTO normalizeManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO) {
CcdiFundGraphManualEdgeSaveDTO dto = saveDTO == null ? new CcdiFundGraphManualEdgeSaveDTO() : saveDTO;
dto.setFromObjectKey(normalizeText(dto.getFromObjectKey()));
dto.setFromName(normalizeText(dto.getFromName()));
dto.setToObjectKey(normalizeText(dto.getToObjectKey()));
dto.setToName(normalizeText(dto.getToName()));
dto.setToIdNo(normalizeText(dto.getToIdNo()));
dto.setDirection(normalizeText(dto.getDirection()));
dto.setRelationDesc(normalizeText(dto.getRelationDesc()));
dto.setSourceDesc(normalizeText(dto.getSourceDesc()));
dto.setRemark(normalizeText(dto.getRemark()));
if (isBlank(dto.getFromObjectKey())) {
throw new IllegalArgumentException("起点主体不能为空");
}
if (isBlank(dto.getToObjectKey()) && isBlank(dto.getToName())) {
throw new IllegalArgumentException("终点主体不能为空");
}
if (isBlank(dto.getDirection())) {
dto.setDirection("1");
}
if (dto.getAmount() == null) {
dto.setAmount(BigDecimal.ZERO);
}
if (dto.getTransactionCount() == null || dto.getTransactionCount() <= 0) {
dto.setTransactionCount(1);
}
return dto;
}
private void addNode(
Map<String, CcdiFundGraphNodeVO> nodeMap,
CcdiFundGraphNodeVO subject,
String nodeKey,
String objectKey,
String nodeName,
String relationType,
Boolean canExpand,
BigDecimal edgeAmount,
Long edgeCount
) {
if (isBlank(nodeKey)) {
return;
}
CcdiFundGraphNodeVO node = nodeMap.computeIfAbsent(nodeKey, key -> {
CcdiFundGraphNodeVO item = new CcdiFundGraphNodeVO();
item.setNodeKey(key);
item.setObjectKey(objectKey);
item.setNodeName(subject != null && !isBlank(subject.getNodeName())
? subject.getNodeName()
: (isBlank(nodeName) ? "未知主体" : nodeName));
item.setIdNo(subject == null ? null : subject.getIdNo());
item.setCinocsno(subject == null ? null : subject.getCinocsno());
item.setIdnoType(subject == null ? null : subject.getIdnoType());
item.setStaffId(subject == null ? null : subject.getStaffId());
item.setSourceType(subject == null ? null : subject.getSourceType());
item.setNodeType(subject != null && !isBlank(subject.getNodeType()) ? subject.getNodeType() : "PERSON");
item.setIdentityType(subject != null && !isBlank(subject.getIdentityType()) ? subject.getIdentityType() : "IDNO");
item.setRelationType(relationType);
item.setAccountCount(subject == null ? 0L : subject.getAccountCount());
item.setCreatedTime(subject == null ? null : subject.getCreatedTime());
item.setUpdatedTime(subject == null ? null : subject.getUpdatedTime());
item.setCanExpand(Boolean.TRUE.equals(canExpand));
item.setDepth(1);
item.setTotalAmount(BigDecimal.ZERO);
item.setTransactionCount(0L);
return item;
});
if (isBlank(node.getObjectKey())) {
node.setObjectKey(objectKey);
}
if (isBlank(node.getRelationType())) {
node.setRelationType(relationType);
}
if (Boolean.TRUE.equals(canExpand)) {
node.setCanExpand(true);
}
node.setTotalAmount(node.getTotalAmount().add(edgeAmount == null ? BigDecimal.ZERO : edgeAmount));
node.setTransactionCount(node.getTransactionCount() + (edgeCount == null ? 0L : edgeCount));
}
private CcdiFundGraphQueryDTO normalizeGraphQuery(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = queryDTO == null ? new CcdiFundGraphQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setObjectKey(normalizeText(query.getObjectKey()));
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
query.setDirection(normalizeText(query.getDirection()));
if (query.getMinTotalAmount() == null) {
query.setMinTotalAmount(DEFAULT_MIN_TOTAL_AMOUNT);
}
query.setLimit(normalizeLimit(query.getLimit()));
query.setDepth(1);
return query;
}
private CcdiFundGraphEdgeDetailQueryDTO normalizeDetailQuery(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
CcdiFundGraphEdgeDetailQueryDTO query = queryDTO == null ? new CcdiFundGraphEdgeDetailQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setFromKey(normalizeText(query.getFromKey()));
query.setToKey(normalizeText(query.getToKey()));
query.setDirection(normalizeText(query.getDirection()));
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
return query;
}
private Integer normalizeLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private List<CcdiFundGraphEdgeVO> sortAndLimitEdges(List<CcdiFundGraphEdgeVO> edges, Integer limit) {
if (edges == null || edges.isEmpty()) {
return Collections.emptyList();
}
List<CcdiFundGraphEdgeVO> sorted = new ArrayList<>(edges);
sorted.sort(EDGE_COMPARATOR);
int finalLimit = normalizeLimit(limit);
if (sorted.size() > finalLimit) {
return List.copyOf(sorted.subList(0, finalLimit));
}
return List.copyOf(sorted);
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private String toSubjectKey(String objectKey) {
return "idno_node/" + objectKey;
}
private String md5(String value) {
return DigestUtils.md5DigestAsHex(value.trim().getBytes(StandardCharsets.UTF_8));
}
private static BigDecimal safeAmount(CcdiFundGraphEdgeVO edge) {
return edge == null || edge.getTotalAmount() == null ? BigDecimal.ZERO : edge.getTotalAmount();
}
private static Long safeTransactionCount(CcdiFundGraphEdgeVO edge) {
return edge == null || edge.getTransactionCount() == null ? 0L : edge.getTransactionCount();
}
private static String safeDateText(CcdiFundGraphEdgeVO edge) {
return normalizeSortText(edge == null ? null : edge.getLastTrxDate());
}
private static String normalizeSortText(String value) {
return value == null ? "" : value;
}
}

View File

@@ -23,6 +23,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -53,6 +54,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
private ICcdiProjectService projectService; private ICcdiProjectService projectService;
@Resource @Resource
@Lazy
private ICcdiBankTagService bankTagService; private ICcdiBankTagService bankTagService;
@Override @Override

View File

@@ -393,6 +393,7 @@ public class CcdiProjectOverviewReportPdfExporter {
private static final float SUBSECTION_FONT_SIZE = 12F; private static final float SUBSECTION_FONT_SIZE = 12F;
private static final float LINE_HEIGHT = 12F; private static final float LINE_HEIGHT = 12F;
private static final float CELL_PADDING = 5F; private static final float CELL_PADDING = 5F;
private static final float TABLE_AFTER_GAP = 32F;
private final PDDocument document; private final PDDocument document;
private final PDType0Font font; private final PDType0Font font;
@@ -420,7 +421,7 @@ public class CcdiProjectOverviewReportPdfExporter {
} }
void title(String text) throws IOException { void title(String text) throws IOException {
writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F); writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F, true);
} }
void text(String text, float fontSize, Color color) throws IOException { void text(String text, float fontSize, Color color) throws IOException {
@@ -429,12 +430,12 @@ public class CcdiProjectOverviewReportPdfExporter {
void section(String text) throws IOException { void section(String text) throws IOException {
ensureSpace(32F); ensureSpace(32F);
writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F); writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F, true);
} }
void subsection(String text) throws IOException { void subsection(String text) throws IOException {
ensureSpace(26F); ensureSpace(26F);
writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F); writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F, true);
} }
void separator() throws IOException { void separator() throws IOException {
@@ -481,7 +482,7 @@ public class CcdiProjectOverviewReportPdfExporter {
for (List<String> row : safeRows) { for (List<String> row : safeRows) {
drawRow(row, widths, false); drawRow(row, widths, false);
} }
y -= 8F; y -= TABLE_AFTER_GAP;
} }
private float[] calculateWidths(float[] ratios) { private float[] calculateWidths(float[] ratios) {
@@ -556,6 +557,17 @@ public class CcdiProjectOverviewReportPdfExporter {
} }
private void writeLine(String text, float fontSize, Color color, float indent, float advance) throws IOException { private void writeLine(String text, float fontSize, Color color, float indent, float advance) throws IOException {
writeLine(text, fontSize, color, indent, advance, false);
}
private void writeLine(
String text,
float fontSize,
Color color,
float indent,
float advance,
boolean bold
) throws IOException {
ensureSpace(advance); ensureSpace(advance);
content.beginText(); content.beginText();
content.setNonStrokingColor(color); content.setNonStrokingColor(color);
@@ -563,6 +575,14 @@ public class CcdiProjectOverviewReportPdfExporter {
content.newLineAtOffset(MARGIN + indent, y); content.newLineAtOffset(MARGIN + indent, y);
content.showText(text); content.showText(text);
content.endText(); content.endText();
if (bold) {
content.beginText();
content.setNonStrokingColor(color);
content.setFont(font, fontSize);
content.newLineAtOffset(MARGIN + indent + 0.25F, y);
content.showText(text);
content.endText();
}
y -= advance; y -= advance;
} }

View File

@@ -57,6 +57,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -90,6 +91,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
private CcdiProjectOverviewReportPdfExporter reportPdfExporter; private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
@Resource @Resource
@Lazy
private ICcdiModelParamService modelParamService; private ICcdiModelParamService modelParamService;
@Override @Override

View File

@@ -0,0 +1,284 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import com.ruoyi.ccdi.project.mapper.CcdiRelationGraphMapper;
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 关系图谱Service实现
*/
@Service
public class CcdiRelationGraphServiceImpl implements ICcdiRelationGraphService {
private static final int DEFAULT_LIMIT = 80;
private static final int MAX_LIMIT = 200;
private static final int DEFAULT_SUSPECTED_LIMIT = 10;
private static final int MAX_SUSPECTED_LIMIT = 20;
private static final int SAME_NAME_BLOCK_THRESHOLD = 20;
private static final String NODE_PREFIX = "rel_node/";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Resource
private CcdiRelationGraphMapper relationGraphMapper;
@Override
public List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
return Collections.emptyList();
}
return relationGraphMapper.selectRelationGraphSubjects(query);
}
@Override
public CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
CcdiRelationGraphNodeVO centerNode = resolveCenterNode(query);
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
return new CcdiRelationGraphVO();
}
query.setObjectKey(centerNode.getObjectKey());
List<CcdiRelationGraphEdgeVO> edges = relationGraphMapper.selectRelationGraphEdges(query);
if (edges == null) {
edges = Collections.emptyList();
}
List<CcdiRelationGraphNodeVO> nodes = buildNodes(centerNode, edges);
CcdiRelationGraphVO graph = new CcdiRelationGraphVO();
graph.setCenterNode(centerNode);
graph.setNodes(nodes);
graph.setEdges(edges);
graph.setEdgeCount((long) edges.size());
graph.setMaxDepth(1);
return graph;
}
@Override
public CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseVO result = new CcdiRelationGraphSuspectedEnterpriseVO();
CcdiRelationGraphSuspectedEnterpriseQueryDTO query = normalizeSuspectedEnterpriseQuery(queryDTO);
if (isBlank(query.getPersonName())) {
result.setMessage("缺少人员姓名,无法按工商同名主体召回");
return result;
}
int sameNameKeyNoCount = relationGraphMapper.countSuspectedEnterpriseKeyNos(query.getPersonName());
result.setSameNameKeyNoCount(sameNameKeyNoCount);
if (sameNameKeyNoCount > SAME_NAME_BLOCK_THRESHOLD) {
result.setBlocked(true);
result.setMessage("同名工商主体过多,请结合交易对手企业名称或其他线索进一步筛选");
return result;
}
LocalDate birthDate = resolveBirthDate(query);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows =
relationGraphMapper.selectSuspectedEnterprises(query.getPersonName(), MAX_SUSPECTED_LIMIT);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> filteredRows = new ArrayList<>();
if (rows != null) {
for (CcdiRelationGraphSuspectedEnterpriseItemVO row : rows) {
if (row == null) {
continue;
}
row.setPersonName(query.getPersonName());
if (applyAgeRule(row, birthDate)) {
filteredRows.add(row);
if (filteredRows.size() >= query.getLimit()) {
break;
}
}
}
}
result.setRows(filteredRows);
if (filteredRows.isEmpty()) {
result.setMessage("未发现可展示的疑似同名企业");
}
return result;
}
private CcdiRelationGraphNodeVO resolveCenterNode(CcdiRelationGraphQueryDTO query) {
List<CcdiRelationGraphNodeVO> subjects = relationGraphMapper.selectRelationGraphSubjects(query);
if (subjects == null || subjects.isEmpty()) {
return null;
}
return subjects.get(0);
}
private List<CcdiRelationGraphNodeVO> buildNodes(CcdiRelationGraphNodeVO centerNode, List<CcdiRelationGraphEdgeVO> edges) {
Set<String> objectKeys = new LinkedHashSet<>();
objectKeys.add(centerNode.getObjectKey());
for (CcdiRelationGraphEdgeVO edge : edges) {
edge.setFromObjectKey(toObjectKey(edge.getFromKey()));
edge.setToObjectKey(toObjectKey(edge.getToKey()));
if (!isBlank(edge.getFromObjectKey())) {
objectKeys.add(edge.getFromObjectKey());
}
if (!isBlank(edge.getToObjectKey())) {
objectKeys.add(edge.getToObjectKey());
}
}
List<CcdiRelationGraphNodeVO> rawNodes = relationGraphMapper.selectRelationGraphNodesByKeys(new ArrayList<>(objectKeys));
Map<String, CcdiRelationGraphNodeVO> nodeMap = new LinkedHashMap<>();
if (rawNodes != null) {
for (CcdiRelationGraphNodeVO node : rawNodes) {
enrichNode(node, centerNode);
nodeMap.put(node.getObjectKey(), node);
}
}
if (!nodeMap.containsKey(centerNode.getObjectKey())) {
enrichNode(centerNode, centerNode);
nodeMap.put(centerNode.getObjectKey(), centerNode);
}
for (CcdiRelationGraphEdgeVO edge : edges) {
CcdiRelationGraphNodeVO fromNode = nodeMap.get(edge.getFromObjectKey());
CcdiRelationGraphNodeVO toNode = nodeMap.get(edge.getToObjectKey());
if (fromNode != null) {
edge.setFromName(fromNode.getNodeName());
}
if (toNode != null) {
edge.setToName(toNode.getNodeName());
}
}
return List.copyOf(nodeMap.values());
}
private void enrichNode(CcdiRelationGraphNodeVO node, CcdiRelationGraphNodeVO centerNode) {
if (node == null) {
return;
}
node.setNodeKey(NODE_PREFIX + node.getObjectKey());
node.setCanExpand(true);
node.setDepth(node.getObjectKey() != null && node.getObjectKey().equals(centerNode.getObjectKey()) ? 0 : 1);
}
private CcdiRelationGraphQueryDTO normalizeGraphQuery(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = queryDTO == null ? new CcdiRelationGraphQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setObjectKey(normalizeText(query.getObjectKey()));
query.setLimit(normalizeLimit(query.getLimit()));
query.setDepth(1);
return query;
}
private CcdiRelationGraphSuspectedEnterpriseQueryDTO normalizeSuspectedEnterpriseQuery(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseQueryDTO query =
queryDTO == null ? new CcdiRelationGraphSuspectedEnterpriseQueryDTO() : queryDTO;
query.setPersonName(normalizeText(query.getPersonName()));
query.setCertNo(normalizeText(query.getCertNo()));
query.setBirthDate(normalizeText(query.getBirthDate()));
query.setLimit(normalizeSuspectedLimit(query.getLimit()));
return query;
}
private Integer normalizeSuspectedLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_SUSPECTED_LIMIT;
}
return Math.min(limit, MAX_SUSPECTED_LIMIT);
}
private LocalDate resolveBirthDate(CcdiRelationGraphSuspectedEnterpriseQueryDTO query) {
LocalDate explicitBirthDate = parseDate(query.getBirthDate());
if (explicitBirthDate != null) {
return explicitBirthDate;
}
return parseBirthDateFromCertNo(query.getCertNo());
}
private boolean applyAgeRule(CcdiRelationGraphSuspectedEnterpriseItemVO row, LocalDate birthDate) {
LocalDate establishDate = parseDate(row.getEstablishDate());
if (birthDate == null || establishDate == null) {
row.setMatchReason("姓名一致;企业成立日期或出生日期缺失,年龄无法判断");
return true;
}
int age = Period.between(birthDate, establishDate).getYears();
row.setAgeAtEstablish(age);
if (age < 18) {
return false;
}
row.setMatchReason("姓名一致;成立时年龄" + age + "");
return true;
}
private LocalDate parseBirthDateFromCertNo(String certNo) {
if (isBlank(certNo)) {
return null;
}
String value = certNo.trim();
if (value.matches("^\\d{17}[0-9Xx]$")) {
return parseCompactDate(value.substring(6, 14));
}
if (value.matches("^\\d{15}$")) {
return parseCompactDate("19" + value.substring(6, 12));
}
return null;
}
private LocalDate parseCompactDate(String value) {
try {
return LocalDate.parse(value, DateTimeFormatter.BASIC_ISO_DATE);
} catch (DateTimeParseException ignored) {
return null;
}
}
private LocalDate parseDate(String value) {
if (isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim(), DATE_FORMATTER);
} catch (DateTimeParseException ignored) {
return null;
}
}
private Integer normalizeLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private String toObjectKey(String nodeKey) {
if (isBlank(nodeKey)) {
return null;
}
return nodeKey.startsWith(NODE_PREFIX) ? nodeKey.substring(NODE_PREFIX.length()) : nodeKey;
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}

View File

@@ -105,36 +105,123 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="salaryExclusionPredicate"> <sql id="salaryExclusionPredicate">
not ( not (
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司' (
and ( bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
IFNULL(bs.USER_MEMO, '') LIKE '%代发%' and (
or IFNULL(bs.USER_MEMO, '') LIKE '%工资%' IFNULL(bs.USER_MEMO, '') LIKE '%代发%'
or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%' or IFNULL(bs.USER_MEMO, '') LIKE '%工资%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪酬%' or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪%' or IFNULL(bs.USER_MEMO, '') LIKE '%薪%'
or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%' or IFNULL(bs.USER_MEMO, '') LIKE '%薪金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%%' or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%' or IFNULL(bs.USER_MEMO, '') LIKE '%%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年%' or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%'
or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%' or IFNULL(bs.USER_MEMO, '') LIKE '%年金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务费%' or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%' or IFNULL(bs.USER_MEMO, '') LIKE '%劳务%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提成%' or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%' or IFNULL(bs.USER_MEMO, '') LIKE '%提成%'
or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%' or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%'
or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%' or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%'
or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%' or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%'
or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%' or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%' or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%'
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%' or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%' or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%' or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%' or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%' or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
)
)
or (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
)
) )
) )
</sql> </sql>
<sql id="financialProductExclusionPredicate">
not (
(
(
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|理财.*托管|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝'
or IFNULL(bs.USER_MEMO, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|本金划出|本金返还|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝|整存整取|智能存款|通知存款'
or IFNULL(bs.CASH_TYPE, '') REGEXP '受托理财|表内理财|购买理财|理财购买|理财扣款|理财申购|理财认购|结构性存款|存款产品|朝朝宝'
or (
IFNULL(bs.USER_MEMO, '') REGEXP '申购|认购|赎回'
and IFNULL(bs.USER_MEMO, '') REGEXP '理财|产品|存款|本金|余额宝|朝朝宝|朝朝盈'
)
)
and IFNULL(bs.USER_MEMO, '') NOT REGEXP '财务|经理|代理财税'
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') NOT LIKE '%代理财政%'
)
)
</sql>
<sql id="thirdPartyWithdrawIncomePredicate">
(
(
bs.BANK in ('ALIPAY', 'WECHAT')
and (
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
)
)
or (
(
bs.BANK is null
or bs.BANK = ''
or bs.BANK not in ('ALIPAY', 'WECHAT')
)
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
or IFNULL(bs.USER_MEMO, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
or IFNULL(bs.CASH_TYPE, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
)
and (
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现到账%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现到账%'
)
)
)
</sql>
<sql id="abnormalCustomerTransactionSubjectSql">
select
staff.id_card as subjectCertNo,
staff.name as subjectName,
'本人' as subjectType
from ccdi_base_staff staff
union all
select
relation.relation_cert_no as subjectCertNo,
relation.relation_name as subjectName,
case
when relation.relation_type is not null and trim(relation.relation_type) != '' then relation.relation_type
else '关系人'
end as subjectType
from ccdi_staff_fmy_relation relation
where relation.status = 1
and relation.relation_cert_no is not null
and trim(relation.relation_cert_no) != ''
</sql>
<sql id="salaryIncomePredicate"> <sql id="salaryIncomePredicate">
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司' bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and ( and (
@@ -167,6 +254,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局' or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
) )
and <include refid="financialProductExclusionPredicate"/>
and ( and (
exists ( exists (
select 1 select 1
@@ -198,6 +286,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款' IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政' or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政'
) )
and <include refid="financialProductExclusionPredicate"/>
and ( and (
exists ( exists (
select 1 select 1
@@ -235,6 +324,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null and relation.person_id is null
and <include refid="salaryExclusionPredicate"/> and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
</select> </select>
<select id="selectCumulativeIncomeObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectCumulativeIncomeObjects" resultMap="BankTagObjectHitResultMap">
@@ -262,6 +352,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null and relation.person_id is null
and <include refid="salaryExclusionPredicate"/> and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold} having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) t ) t
@@ -283,6 +374,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
group by staff.id_card group by staff.id_card
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold} having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
@@ -302,18 +394,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold} and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
and <include refid="cashDepositPredicate"/> and <include refid="cashDepositPredicate"/>
and ( and <include refid="financialProductExclusionPredicate"/>
exists ( and exists (
select 1 select 1
from ccdi_base_staff staff from ccdi_base_staff staff
where staff.id_card = bs.cret_no where staff.id_card = bs.cret_no
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.relation_cert_no = bs.cret_no
and relation.status = 1
)
) )
</select> </select>
@@ -340,16 +425,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold} and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/> and <include refid="cashDepositPredicate"/>
union all and <include refid="financialProductExclusionPredicate"/>
select
relation.person_id AS object_key,
LEFT(TRIM(bs.TRX_DATE), 10) AS cash_date
from ccdi_bank_statement bs
inner join ccdi_staff_fmy_relation relation on relation.relation_cert_no = bs.cret_no
where bs.project_id = #{projectId}
and relation.status = 1
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/>
) source ) source
group by source.object_key, source.cash_date group by source.object_key, source.cash_date
having COUNT(1) > #{frequencyThreshold} having COUNT(1) > #{frequencyThreshold}
@@ -373,8 +449,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
or IFNULL(bs.USER_MEMO, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入' or IFNULL(bs.USER_MEMO, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
or IFNULL(bs.CASH_TYPE, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入' or IFNULL(bs.CASH_TYPE, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
) )
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%款%'
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and ( and (
exists ( exists (
select 1 select 1
@@ -392,12 +468,130 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
select select
bs.bank_statement_id AS bankStatementId, hit.bankStatementId AS bankStatementId,
bs.group_id AS groupId, max(hit.groupId) AS groupId,
bs.batch_id AS logId, max(hit.logId) AS logId,
'占位SQL待补充真实规则' AS reasonDetail substring_index(
from ccdi_bank_statement bs min(concat(lpad(hit.matchPriority, 2, '0'), '|', hit.reasonDetail)),
where 1 = 0 '|',
-1
) AS reasonDetail
from (
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
1 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与信贷客户账号发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_account_info account
on trim(IFNULL(bs.customer_account_no, '')) != ''
and account.owner_type = 'CREDIT_CUSTOMER'
and account.account_no = bs.customer_account_no
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
2 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介账号发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_account_info account
on trim(IFNULL(bs.customer_account_no, '')) != ''
and account.owner_type = 'INTERMEDIARY'
and account.account_no = bs.customer_account_no
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
3 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介关联企业发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_enterprise_base_info enterprise
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and enterprise.enterprise_name = bs.CUSTOMER_ACCOUNT_NAME
and enterprise.ent_source = 'INTERMEDIARY'
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
4 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生微信/支付宝交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_biz_intermediary intermediary
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and trim(IFNULL(intermediary.name, '')) != ''
and bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
and bs.bank in ('ALIPAY', 'WECHAT')
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
5 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生名称精确匹配交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_biz_intermediary intermediary
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
) hit
group by hit.bankStatementId
</select> </select>
<select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap">
@@ -416,9 +610,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_staff_fmy_relation relation from ccdi_staff_fmy_relation relation
inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no
where relation.status = 1 where relation.status = 1
and relation.annual_income is not null
and ( and (
relation.annual_income is null relation.annual_income = 0
or relation.annual_income = 0
or relation.annual_income / 12 &lt; 3000 or relation.annual_income / 12 &lt; 3000
) )
and bs.project_id = #{projectId} and bs.project_id = #{projectId}
@@ -503,8 +697,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0 and IFNULL(bs.AMOUNT_DR, 0) > 0
and ( and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注' IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注' or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
) )
</select> </select>
@@ -658,6 +852,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0 and IFNULL(bs.AMOUNT_CR, 0) > 0
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; '浙江兰溪农村商业银行股份有限公司' and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; '浙江兰溪农村商业银行股份有限公司'
and not (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
)
)
and ( and (
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees' IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费' or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费'
@@ -1055,6 +1258,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证' or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证' or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
) )
and <include refid="financialProductExclusionPredicate"/>
</select> </select>
<select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap">
@@ -1074,11 +1278,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_bank_statement bs from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) >= 0 and IFNULL(bs.AMOUNT_CR, 0) > 0
and ( and <include refid="thirdPartyWithdrawIncomePredicate"/>
IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
)
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10) group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having COUNT(1) > #{frequencyThreshold} having COUNT(1) > #{frequencyThreshold}
) t ) t
@@ -1087,10 +1288,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '单日微信/支付宝提现到账金额 ', CAST(t.withdrawAmount AS CHAR),
where 1 = 0 ' 元,超过阈值 ', CAST(#{amountThreshold} AS CHAR),
' 元,交易日:', t.transDate
) AS reasonDetail
from (
select
staff.id_card AS objectKey,
LEFT(TRIM(bs.TRX_DATE), 10) AS transDate,
ROUND(SUM(IFNULL(bs.AMOUNT_CR, 0)), 2) AS withdrawAmount
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0
and <include refid="thirdPartyWithdrawIncomePredicate"/>
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{amountThreshold}
) t
</select> </select>
<select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap">
@@ -1322,10 +1538,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId} where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold} and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and ( and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管' IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管' or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管' or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
) )
and <include refid="financialProductExclusionPredicate"/>
</select> </select>
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">

View File

@@ -0,0 +1,384 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiFundGraphMapper">
<resultMap id="FundGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO">
<id property="objectKey" column="objectKey"/>
<result property="nodeKey" column="nodeKey"/>
<result property="nodeName" column="nodeName"/>
<result property="idNo" column="idNo"/>
<result property="cinocsno" column="cinocsno"/>
<result property="idnoType" column="idnoType"/>
<result property="staffId" column="staffId"/>
<result property="sourceType" column="sourceType"/>
<result property="nodeType" column="nodeType"/>
<result property="identityType" column="identityType"/>
<result property="relationType" column="relationType"/>
<result property="accountCount" column="accountCount"/>
<result property="createdTime" column="createdTime"/>
<result property="updatedTime" column="updatedTime"/>
<result property="canExpand" column="canExpand"/>
<result property="depth" column="depth"/>
<result property="totalAmount" column="totalAmount"/>
<result property="transactionCount" column="transactionCount"/>
</resultMap>
<resultMap id="FundGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO">
<id property="edgeKey" column="edgeKey"/>
<result property="fromKey" column="fromKey"/>
<result property="toKey" column="toKey"/>
<result property="fromObjectKey" column="fromObjectKey"/>
<result property="toObjectKey" column="toObjectKey"/>
<result property="fromName" column="fromName"/>
<result property="toName" column="toName"/>
<result property="totalAmount" column="totalAmount"/>
<result property="transactionCount" column="transactionCount"/>
<result property="firstTrxDate" column="firstTrxDate"/>
<result property="lastTrxDate" column="lastTrxDate"/>
<result property="direction" column="direction"/>
<result property="familyRelationType" column="familyRelationType"/>
<result property="sourceType" column="sourceType"/>
<result property="relationDesc" column="relationDesc"/>
<result property="sourceDesc" column="sourceDesc"/>
<result property="remark" column="remark"/>
<result property="depth" column="depth"/>
<result property="canTrace" column="canTrace"/>
</resultMap>
<resultMap id="FundGraphStatementResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/>
<result property="leAccountNo" column="leAccountNo"/>
<result property="leAccountName" column="leAccountName"/>
<result property="customerAccountName" column="customerAccountName"/>
<result property="customerAccountNo" column="customerAccountNo"/>
<result property="cashType" column="cashType"/>
<result property="userMemo" column="userMemo"/>
<result property="amount" column="amount"/>
<result property="direction" column="direction"/>
<result property="familyRelationType" column="familyRelationType"/>
</resultMap>
<sql id="detailFilter">
<if test="query.transactionStartTime != null and query.transactionStartTime != ''">
AND d.trx_date <![CDATA[ >= ]]> (#{query.transactionStartTime} COLLATE utf8mb4_general_ci)
</if>
<if test="query.transactionEndTime != null and query.transactionEndTime != ''">
AND d.trx_date <![CDATA[ <= ]]>
(CASE
WHEN LENGTH(TRIM(#{query.transactionEndTime})) = 10
THEN CONCAT(TRIM(#{query.transactionEndTime}), ' 23:59:59')
ELSE TRIM(#{query.transactionEndTime})
END COLLATE utf8mb4_general_ci)
</if>
<if test="query.amountMin != null">
AND d.amount <![CDATA[ >= ]]> #{query.amountMin}
</if>
<if test="query.amountMax != null">
AND d.amount <![CDATA[ <= ]]> #{query.amountMax}
</if>
<if test="query.direction != null and query.direction != ''">
AND d.flag = (#{query.direction} COLLATE utf8mb4_general_ci)
</if>
</sql>
<sql id="subjectJoinRows">
SELECT
d.object_key AS detailObjectKey,
d.bank_statement_id AS bankStatementId,
d.trx_date AS trxDate,
d.le_account_no AS leAccountNo,
d.le_account_name AS leAccountName,
d.customer_account_name AS customerAccountName,
d.customer_account_no AS customerAccountNo,
d.cash_type AS cashType,
d.user_memo AS userMemo,
d.amount,
d.flag AS direction,
d.family_relation_type AS familyRelationType,
from_subject.object_key AS fromObjectKey,
to_subject.object_key AS toObjectKey,
CONCAT('idno_node/', from_subject.object_key) AS fromKey,
CONCAT('idno_node/', to_subject.object_key) AS toKey,
from_subject.name AS fromName,
to_subject.name AS toName,
from_subject.idnocfno AS fromIdNo,
to_subject.idnocfno AS toIdNo
FROM lx_fund_flow_detail_edge d
INNER JOIN lx_fund_flow_own_account_edge from_own
ON from_own.to_key = d.from_key
INNER JOIN lx_fund_flow_subject_node from_subject
ON CONCAT('idno_node/', from_subject.object_key) = from_own.from_key
INNER JOIN lx_fund_flow_own_account_edge to_own
ON to_own.to_key = d.to_key
INNER JOIN lx_fund_flow_subject_node to_subject
ON CONCAT('idno_node/', to_subject.object_key) = to_own.from_key
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND (
from_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
OR to_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
)
</if>
<include refid="detailFilter"/>
</sql>
<select id="selectFundGraphSubjects" resultMap="FundGraphNodeResultMap">
SELECT
n.object_key AS objectKey,
CONCAT('idno_node/', n.object_key) AS nodeKey,
n.name AS nodeName,
n.idnocfno AS idNo,
n.cinocsno AS cinocsno,
n.idno_type AS idnoType,
n.staff_id AS staffId,
n.source_type AS sourceType,
CASE
WHEN n.idno_type = 'NAME_PROXY' OR n.source_type LIKE '%COUNTERPARTY%' THEN 'PROXY'
ELSE 'PERSON'
END AS nodeType,
CASE
WHEN n.idnocfno IS NOT NULL AND TRIM(n.idnocfno) != '' THEN 'IDNO'
ELSE 'NAME'
END AS identityType,
CASE
WHEN n.source_type LIKE 'GRAPH_TEST_FAMILY_%' THEN REPLACE(n.source_type, 'GRAPH_TEST_FAMILY_', '')
ELSE NULL
END AS relationType,
CASE
WHEN EXISTS (
SELECT 1
FROM lx_fund_flow_own_account_edge own
WHERE own.from_key = CONCAT('idno_node/', n.object_key)
) THEN 1
ELSE 0
END AS canExpand,
(
SELECT COUNT(1)
FROM lx_fund_flow_own_account_edge own_count
WHERE own_count.from_key = CONCAT('idno_node/', n.object_key)
) AS accountCount,
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
0 AS depth,
0 AS totalAmount,
0 AS transactionCount
FROM lx_fund_flow_subject_node n
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
</if>
<if test="query.objectKey == null or query.objectKey == ''">
<if test="query.keyword != null and query.keyword != ''">
AND (
n.idnocfno = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
OR n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
)
</if>
</if>
ORDER BY
CASE
WHEN n.idnocfno = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
WHEN n.staff_id IS NOT NULL AND TRIM(n.staff_id) != '' THEN 2
WHEN UPPER(IFNULL(n.source_type, '')) LIKE '%EMPLOYEE%' THEN 2
WHEN n.source_type LIKE '%员工%' THEN 2
ELSE 3
END,
n.name
LIMIT
<choose>
<when test="query.limit != null and query.limit > 0">
#{query.limit}
</when>
<otherwise>
20
</otherwise>
</choose>
</select>
<select id="selectFundGraphEdges" resultMap="FundGraphEdgeResultMap">
SELECT
MD5(CONCAT(graph_rows.fromKey, '|', graph_rows.toKey, '|', graph_rows.direction, '|', IFNULL(graph_rows.familyRelationType, ''))) AS edgeKey,
graph_rows.fromKey,
graph_rows.toKey,
graph_rows.fromObjectKey,
graph_rows.toObjectKey,
MAX(graph_rows.fromName) AS fromName,
MAX(graph_rows.toName) AS toName,
SUM(graph_rows.amount) AS totalAmount,
COUNT(1) AS transactionCount,
MIN(graph_rows.trxDate) AS firstTrxDate,
MAX(graph_rows.trxDate) AS lastTrxDate,
graph_rows.direction,
graph_rows.familyRelationType,
'BANK' AS sourceType,
NULL AS relationDesc,
NULL AS sourceDesc,
NULL AS remark,
1 AS depth,
CASE
WHEN MAX(
CASE
WHEN graph_rows.fromObjectKey = #{query.objectKey} THEN
CASE
WHEN graph_rows.toIdNo IS NOT NULL AND TRIM(graph_rows.toIdNo) != '' THEN 1
ELSE 0
END
ELSE
CASE
WHEN graph_rows.fromIdNo IS NOT NULL AND TRIM(graph_rows.fromIdNo) != '' THEN 1
ELSE 0
END
END
) = 1 THEN 1
WHEN EXISTS (
SELECT 1
FROM lx_fund_flow_own_account_edge own
WHERE own.from_key = CASE
WHEN graph_rows.fromObjectKey = #{query.objectKey}
THEN graph_rows.toKey
ELSE graph_rows.fromKey
END
) THEN 1
ELSE 0
END AS canTrace
FROM (
<include refid="subjectJoinRows"/>
) graph_rows
WHERE 1 = 1
GROUP BY
graph_rows.fromKey,
graph_rows.toKey,
graph_rows.fromObjectKey,
graph_rows.toObjectKey,
graph_rows.direction,
graph_rows.familyRelationType
<if test="query.minTotalAmount != null">
HAVING SUM(graph_rows.amount) <![CDATA[ >= ]]> #{query.minTotalAmount}
</if>
ORDER BY totalAmount DESC, transactionCount DESC
LIMIT #{query.limit}
</select>
<select id="selectFundGraphManualEdges" resultMap="FundGraphEdgeResultMap">
SELECT
m.object_key AS edgeKey,
CONCAT('idno_node/', m.from_object_key) AS fromKey,
CONCAT('idno_node/', m.to_object_key) AS toKey,
m.from_object_key AS fromObjectKey,
m.to_object_key AS toObjectKey,
COALESCE(from_subject.name, m.from_name) AS fromName,
COALESCE(to_subject.name, m.to_name) AS toName,
m.amount AS totalAmount,
m.transaction_count AS transactionCount,
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS firstTrxDate,
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS lastTrxDate,
m.direction,
NULL AS familyRelationType,
m.source_type AS sourceType,
m.relation_desc AS relationDesc,
m.source_desc AS sourceDesc,
m.remark,
1 AS depth,
0 AS canTrace
FROM lx_fund_flow_manual_edge m
LEFT JOIN lx_fund_flow_subject_node from_subject
ON from_subject.object_key = m.from_object_key
LEFT JOIN lx_fund_flow_subject_node to_subject
ON to_subject.object_key = m.to_object_key
WHERE m.source_type = 'MANUAL'
AND (
m.from_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
OR m.to_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
)
ORDER BY m.updated_time DESC
LIMIT #{query.limit}
</select>
<select id="selectFundGraphEdgeDetails" resultMap="FundGraphStatementResultMap">
SELECT
graph_rows.bankStatementId,
graph_rows.trxDate,
graph_rows.leAccountNo,
graph_rows.leAccountName,
graph_rows.customerAccountName,
graph_rows.customerAccountNo,
graph_rows.cashType,
graph_rows.userMemo,
graph_rows.amount,
graph_rows.direction,
graph_rows.familyRelationType
FROM (
<include refid="subjectJoinRows"/>
) graph_rows
WHERE graph_rows.fromKey = (#{query.fromKey} COLLATE utf8mb4_general_ci)
AND graph_rows.toKey = (#{query.toKey} COLLATE utf8mb4_general_ci)
<if test="query.direction != null and query.direction != ''">
AND graph_rows.direction = (#{query.direction} COLLATE utf8mb4_general_ci)
</if>
ORDER BY graph_rows.trxDate DESC, graph_rows.bankStatementId DESC
</select>
<select id="countSubjectByObjectKey" resultType="int">
SELECT COUNT(1)
FROM lx_fund_flow_subject_node
WHERE object_key = (#{objectKey} COLLATE utf8mb4_general_ci)
</select>
<insert id="insertManualSubject">
INSERT IGNORE INTO lx_fund_flow_subject_node (
object_key,
idnocfno,
name,
idno_type,
source_type
) VALUES (
#{objectKey},
#{idNo},
#{name},
CASE
WHEN #{idNo} IS NULL OR TRIM(#{idNo}) = '' THEN 'NAME_PROXY'
ELSE 'PERSON'
END,
'MANUAL'
)
</insert>
<insert id="insertManualEdge">
INSERT INTO lx_fund_flow_manual_edge (
object_key,
from_object_key,
to_object_key,
from_name,
to_name,
amount,
transaction_count,
direction,
relation_desc,
source_desc,
remark,
source_type,
created_by,
updated_by
) VALUES (
#{objectKey},
#{dto.fromObjectKey},
#{dto.toObjectKey},
#{dto.fromName},
#{dto.toName},
#{dto.amount},
#{dto.transactionCount},
#{dto.direction},
#{dto.relationDesc},
#{dto.sourceDesc},
#{dto.remark},
'MANUAL',
#{operator},
#{operator}
)
</insert>
</mapper>

View File

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

View File

@@ -0,0 +1,330 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiRelationGraphMapper">
<resultMap id="RelationGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO">
<id property="objectKey" column="objectKey"/>
<result property="nodeKey" column="nodeKey"/>
<result property="nodeName" column="nodeName"/>
<result property="idNumber" column="idNumber"/>
<result property="subjectType" column="subjectType"/>
<result property="sourceType" column="sourceType"/>
<result property="createdTime" column="createdTime"/>
<result property="updatedTime" column="updatedTime"/>
<result property="canExpand" column="canExpand"/>
<result property="depth" column="depth"/>
</resultMap>
<resultMap id="RelationGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO">
<id property="objectKey" column="objectKey"/>
<result property="fromKey" column="fromKey"/>
<result property="toKey" column="toKey"/>
<result property="edgeTable" column="edgeTable"/>
<result property="relationType" column="relationType"/>
<result property="companyName" column="companyName"/>
<result property="stockName" column="stockName"/>
<result property="stockType" column="stockType"/>
<result property="stockPercent" column="stockPercent"/>
<result property="shouldCapi" column="shouldCapi"/>
<result property="shouldCapiValue" column="shouldCapiValue"/>
<result property="shouldCapiUnit" column="shouldCapiUnit"/>
<result property="shoudDate" column="shoudDate"/>
<result property="pKeyNo" column="pKeyNo"/>
<result property="operName" column="operName"/>
<result property="operKeyNo" column="operKeyNo"/>
<result property="personId" column="personId"/>
<result property="relationName" column="relationName"/>
<result property="relationCertNo" column="relationCertNo"/>
<result property="gender" column="gender"/>
<result property="birthDate" column="birthDate"/>
<result property="relationCertType" column="relationCertType"/>
<result property="mobilePhone1" column="mobilePhone1"/>
<result property="mobilePhone2" column="mobilePhone2"/>
<result property="wechatNo1" column="wechatNo1"/>
<result property="wechatNo2" column="wechatNo2"/>
<result property="wechatNo3" column="wechatNo3"/>
<result property="contactAddress" column="contactAddress"/>
<result property="annualIncome" column="annualIncome"/>
<result property="relationDesc" column="relationDesc"/>
<result property="status" column="status"/>
<result property="effectiveDate" column="effectiveDate"/>
<result property="invalidDate" column="invalidDate"/>
<result property="remark" column="remark"/>
<result property="dataSource" column="dataSource"/>
</resultMap>
<resultMap id="SuspectedEnterpriseResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO">
<result property="candidateKeyNo" column="candidateKeyNo"/>
<result property="personName" column="personName"/>
<result property="companyId" column="companyId"/>
<result property="companyName" column="companyName"/>
<result property="creditCode" column="creditCode"/>
<result property="enterpriseStatus" column="enterpriseStatus"/>
<result property="industryName" column="industryName"/>
<result property="relationType" column="relationType"/>
<result property="stockPercent" column="stockPercent"/>
<result property="establishDate" column="establishDate"/>
</resultMap>
<sql id="nodeColumns">
n.object_key AS objectKey,
CONCAT('rel_node/', n.object_key) AS nodeKey,
n.node_name AS nodeName,
n.id_number AS idNumber,
n.subject_type AS subjectType,
n.source_type AS sourceType,
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
1 AS canExpand,
0 AS depth
</sql>
<select id="selectRelationGraphSubjects" resultMap="RelationGraphNodeResultMap">
SELECT
<include refid="nodeColumns"/>
FROM lx_rel_node n
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
</if>
<if test="query.objectKey == null or query.objectKey == ''">
<if test="query.keyword != null and query.keyword != ''">
AND (
n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.id_number = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.node_name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
)
</if>
</if>
ORDER BY
CASE
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
WHEN n.id_number = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
ELSE 2
END,
n.node_name
LIMIT 20
</select>
<select id="selectRelationGraphNodesByKeys" resultMap="RelationGraphNodeResultMap">
SELECT
<include refid="nodeColumns"/>
FROM lx_rel_node n
WHERE n.object_key IN
<foreach collection="objectKeys" item="objectKey" open="(" separator="," close=")">
#{objectKey}
</foreach>
</select>
<select id="selectRelationGraphEdges" resultMap="RelationGraphEdgeResultMap">
SELECT *
FROM (
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_family_edge' AS edgeTable,
e.relation_type AS relationType,
NULL AS companyName,
NULL AS stockName,
NULL AS stockType,
NULL AS stockPercent,
NULL AS shouldCapi,
NULL AS shouldCapiValue,
NULL AS shouldCapiUnit,
NULL AS shoudDate,
NULL AS pKeyNo,
NULL AS operName,
NULL AS operKeyNo,
e.person_id AS personId,
e.relation_name AS relationName,
e.relation_cert_no AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
e.relation_desc AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_family_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
UNION ALL
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_stock_edge' AS edgeTable,
e.stock_type AS relationType,
e.company_name AS companyName,
e.stock_name AS stockName,
e.stock_type AS stockType,
e.stock_percent AS stockPercent,
e.should_capi AS shouldCapi,
e.should_capi_value AS shouldCapiValue,
e.should_capi_unit AS shouldCapiUnit,
e.shoud_date AS shoudDate,
e.p_key_no AS pKeyNo,
NULL AS operName,
NULL AS operKeyNo,
NULL AS personId,
NULL AS relationName,
NULL AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
NULL AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_stock_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
UNION ALL
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_represent_edge' AS edgeTable,
'法定代表人' AS relationType,
NULL AS companyName,
NULL AS stockName,
NULL AS stockType,
NULL AS stockPercent,
NULL AS shouldCapi,
NULL AS shouldCapiValue,
NULL AS shouldCapiUnit,
NULL AS shoudDate,
NULL AS pKeyNo,
e.oper_name AS operName,
e.oper_key_no AS operKeyNo,
NULL AS personId,
NULL AS relationName,
NULL AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
NULL AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_represent_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
) graph_edges
ORDER BY
CASE edgeTable
WHEN 'lx_rel_family_edge' THEN 0
WHEN 'lx_rel_stock_edge' THEN 1
ELSE 2
END,
relationType,
objectKey
LIMIT #{query.limit}
</select>
<select id="countSuspectedEnterpriseKeyNos" resultType="int">
SELECT COUNT(DISTINCT candidate_key_no)
FROM (
SELECT e.oper_key_no AS candidate_key_no
FROM lx_rel_represent_edge e
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.oper_key_no IS NOT NULL
AND e.oper_key_no != ''
UNION ALL
SELECT e.p_key_no AS candidate_key_no
FROM lx_rel_stock_edge e
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.stock_type = '自然人股东'
AND e.p_key_no IS NOT NULL
AND e.p_key_no != ''
) same_name_candidates
</select>
<select id="selectSuspectedEnterprises" resultMap="SuspectedEnterpriseResultMap">
SELECT *
FROM (
SELECT
e.oper_key_no AS candidateKeyNo,
e.oper_name AS personName,
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
COALESCE(ent.enterprise_name, company_node.node_name) AS companyName,
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
ent.status AS enterpriseStatus,
ent.industry_name AS industryName,
'法定代表人' AS relationType,
NULL AS stockPercent,
DATE_FORMAT(ent.establish_date, '%Y-%m-%d') AS establishDate,
0 AS relationSort
FROM lx_rel_represent_edge e
LEFT JOIN lx_rel_node company_node
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
LEFT JOIN ccdi_enterprise_base_info ent
ON ent.social_credit_code = company_node.id_number
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.oper_key_no IS NOT NULL
AND e.oper_key_no != ''
UNION ALL
SELECT
e.p_key_no AS candidateKeyNo,
e.stock_name AS personName,
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
COALESCE(ent.enterprise_name, e.company_name, company_node.node_name) AS companyName,
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
ent.status AS enterpriseStatus,
ent.industry_name AS industryName,
e.stock_type AS relationType,
e.stock_percent AS stockPercent,
COALESCE(DATE_FORMAT(ent.establish_date, '%Y-%m-%d'), e.shoud_date) AS establishDate,
1 AS relationSort
FROM lx_rel_stock_edge e
LEFT JOIN lx_rel_node company_node
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
LEFT JOIN ccdi_enterprise_base_info ent
ON ent.social_credit_code = company_node.id_number
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.stock_type = '自然人股东'
AND e.p_key_no IS NOT NULL
AND e.p_key_no != ''
) suspected_enterprises
ORDER BY relationSort, companyName, companyId
LIMIT #{limit}
</select>
</mapper>

View File

@@ -100,7 +100,7 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception { void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE); String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则")); assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(6, countMatches(xml, "where 1 = 0")); assertEquals(5, countMatches(xml, "where 1 = 0"));
} }
@Test @Test
@@ -116,6 +116,31 @@ class CcdiBankTagAnalysisMapperXmlTest {
} }
} }
@Test
void lowIncomeRelativeRule_shouldIgnoreNullAnnualIncome() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectLowIncomeRelativeLargeTransactionObjects");
assertTrue(selectSql.contains("relation.annual_income is not null"));
assertTrue(!selectSql.contains("relation.annual_income is null"));
}
@Test
void abnormalCustomerTransactionRule_shouldUseCreditCustomerAndIntermediaryAccountRules() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectAbnormalCustomerTransactionStatements");
assertTrue(selectSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"));
assertTrue(selectSql.contains("account.owner_type = 'INTERMEDIARY'"));
assertTrue(selectSql.contains("account.account_no = bs.customer_account_no"));
assertTrue(selectSql.contains("enterprise.ent_source = 'INTERMEDIARY'"));
assertTrue(selectSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"));
assertTrue(selectSql.contains("bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')"));
assertTrue(selectSql.contains("bs.bank in ('ALIPAY', 'WECHAT')"));
assertEquals(5, countMatches(selectSql, "GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"));
assertTrue(!selectSql.contains("customer_cert_no"));
assertTrue(!selectSql.contains("social_credit_code = bs"));
}
@Test @Test
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception { void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE); String xml = readXml(RESOURCE);

View File

@@ -90,13 +90,50 @@ class CcdiProjectOverviewMapperSqlTest {
void shouldExposeSuspiciousTransactionAggregationQuery() throws Exception { void shouldExposeSuspiciousTransactionAggregationQuery() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml")); String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage"); String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage");
String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
assertTrue(suspiciousSql.contains("rule_name like '%可疑%'"), suspiciousSql); assertTrue(modelHitSql.contains("from ccdi_bank_statement_tag_result tr"), modelHitSql);
assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql);
assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql);
assertFalse(modelHitSql.contains("ABNORMAL_CUSTOMER_TRANSACTION"), modelHitSql);
assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql); assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql);
assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql); assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql);
assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql); assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql);
assertTrue(aggregatedSql.contains("lpad(merged.matchPriority, 2, '0')"), aggregatedSql);
assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql); assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql); assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("final_result.nameListHitType"), suspiciousSql);
String reportSuspiciousSql = extractSelect(xml, "selectReportSuspiciousTransactionList");
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '中介'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
}
@Test
void suspiciousTransactionNameListSql_shouldKeepCreditCustomerAndIntermediaryRulesScopedByAmount() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String nameHitSql = extractSqlFragment(xml, "suspiciousTransactionNameHitSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
assertTrue(nameHitSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"), nameHitSql);
assertTrue(nameHitSql.contains("account.owner_type = 'INTERMEDIARY'"), nameHitSql);
assertTrue(nameHitSql.contains("account.account_no = bs.customer_account_no"), nameHitSql);
assertTrue(nameHitSql.contains("'信贷客户' as nameListHitType"), nameHitSql);
assertTrue(nameHitSql.contains("'中介' as nameListHitType"), nameHitSql);
assertTrue(nameHitSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"), nameHitSql);
assertTrue(nameHitSql.contains("enterprise.ent_source = 'INTERMEDIARY'"), nameHitSql);
assertTrue(
nameHitSql.contains("GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"),
nameHitSql
);
assertFalse(nameHitSql.contains("customer_cert_no"), nameHitSql);
assertFalse(nameHitSql.contains("social_credit_code = bs"), nameHitSql);
assertTrue(aggregatedSql.contains("group by merged.bankStatementId"), aggregatedSql);
assertTrue(aggregatedSql.contains("max(merged.hasModelRuleHit) as hasModelRuleHit"), aggregatedSql);
assertTrue(aggregatedSql.contains("max(merged.hasNameListHit) as hasNameListHit"), aggregatedSql);
} }
@Test @Test
@@ -159,4 +196,13 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(endIndex >= 0, "missing closing select tag: " + selectId); assertTrue(endIndex >= 0, "missing closing select tag: " + selectId);
return xml.substring(startIndex, endIndex); return xml.substring(startIndex, endIndex);
} }
private String extractSqlFragment(String xml, String sqlId) {
String start = "<sql id=\"" + sqlId + "\"";
int startIndex = xml.indexOf(start);
assertTrue(startIndex >= 0, "missing sql fragment: " + sqlId);
int endIndex = xml.indexOf("</sql>", startIndex);
assertTrue(endIndex >= 0, "missing closing sql tag: " + sqlId);
return xml.substring(startIndex, endIndex);
}
} }

View File

@@ -36,6 +36,17 @@ class CcdiProjectOverviewReportPdfExporterTest {
assertTrue(response.getContentAsByteArray().length > 1000); assertTrue(response.getContentAsByteArray().length > 1000);
} }
@Test
void tableGap_shouldLeaveEnoughSpaceForNextSectionTitle() throws Exception {
Class<?> writerClass = Class.forName(
"com.ruoyi.ccdi.project.service.impl.CcdiProjectOverviewReportPdfExporter$PdfPageWriter"
);
float tableAfterGap = readPrivateFloat(writerClass, "TABLE_AFTER_GAP");
float sectionFontSize = readPrivateFloat(writerClass, "SECTION_FONT_SIZE");
assertTrue(tableAfterGap > sectionFontSize);
}
private CcdiProjectOverviewReportVO buildReport() { private CcdiProjectOverviewReportVO buildReport() {
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO(); CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
CcdiProject project = new CcdiProject(); CcdiProject project = new CcdiProject();
@@ -53,6 +64,12 @@ class CcdiProjectOverviewReportPdfExporterTest {
return report; return report;
} }
private float readPrivateFloat(Class<?> clazz, String fieldName) throws Exception {
java.lang.reflect.Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
return field.getFloat(null);
}
private CcdiProjectOverviewDashboardVO buildDashboard() { private CcdiProjectOverviewDashboardVO buildDashboard() {
CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO(); CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
dashboard.setStats(List.of( dashboard.setStats(List.of(

View File

@@ -0,0 +1,26 @@
# Fund Graph Backend Implementation Plan
**目标:** 基于图谱结果表和手工补录边提供一期资金流图谱后端能力,支持按身份证号或员工姓名查询一层资金往来,并支持点击图谱边后分页查看该边每一笔流水。
**一期范围:**
- 搜索条件:`projectId` 保留为历史字段但不参与过滤,`keyword` 支持身份证号精确匹配、员工姓名匹配。
- 图谱范围:仅返回当前人员或姓名命中的直接对手方资金往来,不自动追溯二、三层。
- 边明细:按图谱边的 `fromKey/toKey` 查询每笔流水,保留交易时间、本方、对手方、摘要、金额、方向。
- 预留扩展DTO/VO 保留 `depth``canTrace``canExpand` 字段,后续可以扩展节点点击追溯。
**实现内容:**
- 新增 `CcdiFundGraphController`,暴露 `/ccdi/project/fund-graph/search``/ccdi/project/fund-graph/graph``/ccdi/project/fund-graph/edge-detail``/ccdi/project/fund-graph/manual-edge`
- 新增 `ICcdiFundGraphService``CcdiFundGraphServiceImpl`负责查询参数归一化、TopN 限制、节点构建和追溯字段赋值。
- 新增 `CcdiFundGraphMapper` 与 XML SQL基于 `lx_fund_flow_*` 图谱结果表查询主体、汇总边、逐笔流水,并支持手工边落库。
- 新增 DTO/VO`CcdiFundGraphQueryDTO``CcdiFundGraphEdgeDetailQueryDTO``CcdiFundGraphVO``CcdiFundGraphNodeVO``CcdiFundGraphEdgeVO``CcdiFundGraphStatementVO`
**数据口径:**
- 一期查询不按 `projectId` 过滤,统一按全局图谱关系查询。
- 资金边来自 `lx_fund_flow_detail_edge` 归并后的主体层汇总边,并叠加 `lx_fund_flow_manual_edge` 手工边。
- 图谱边按 `fromKey/toKey/direction/familyRelationType` 聚合,统计累计金额、交易笔数、首末交易时间。
- 手工边与真实边统一排序后再按 `limit` 截断,避免结果条数和排序口径不一致。
**后续追溯口子:**
- 当前接口接收 `depth` 但服务层固定为一期一层。
- 节点返回 `canExpand`,边返回 `canTrace`,后续可新增 `rootKey/currentNodeKey/depth` 参数做按节点展开。
- 一期 SQL 已把人员、企业、账号、名称代理做成统一节点键,后续多层追溯可沿用同一套节点键。

View File

@@ -0,0 +1,24 @@
# 2026-05-06 项目分析个人详情页样式对齐前端实施计划
## 目标
- 将项目分析个人详情页样式对齐到用户提供的参考图。
- 本次只调整前端样式表现,不改接口、字段、交互逻辑和业务内容。
## 范围
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue`
## 实施要点
- 调整详情弹窗头部、左右分栏比例、页签尺寸和间距。
- 调整左侧人物档案与命中模型摘要区块的标题、信息行、风险徽标和标签样式。
- 调整右侧异常明细内容区的区块标题、表格头部、单元格留白、异常对象摘要卡片和快照块样式。
- 保持现有数据绑定、页签切换、证据库按钮和分页逻辑不变。
## 验证
- 在真实业务页面打开项目总览详情弹窗,检查个人详情页视觉是否与参考图一致。
- 确认异常明细、对象摘要、加入证据库按钮和分页仍可正常显示。

View File

@@ -0,0 +1,29 @@
# Fund Graph Frontend Implementation Plan
**目标:** 在专项排查页签落地完整版图谱工作台,在项目分析弹窗内落地简版图谱展示,支持查看一层资金流和关系图谱,并通过点击资金边查看代表性流水。
**一期范围:**
- 专项排查版保留搜索栏:身份证号/员工姓名、交易时间范围、最小汇总金额。
- 项目分析弹窗版不提供搜索栏与手工新增入口,使用当前人员自动定位图谱。
- 图谱区:用 ECharts force graph 展示人员、企业、账号代理、名称代理节点和有向资金边。
- 汇总区:展示节点数、资金边数、交易笔数、汇总金额。
- 明细区:点击任意边后展示累计金额、交易笔数、最近交易和关系标签。
- 专项排查版保留分页逐笔流水;项目分析弹窗版仅展示最近 5 条代表性流水。
**实现内容:**
- 新增 `src/api/ccdi/graph/fundGraph.js``src/api/ccdi/graph/relationGraph.js`,封装图谱接口。
- 新增 `ProjectAnalysisFundFlowTab.vue`,承接项目分析弹窗内的简版图谱展示。
- 新增 `graph/FundGraphSection.vue`,统一承载完整版和弹窗简版两种模式。
- 修改 `ProjectAnalysisDialog.vue``SpecialCheck.vue`,分别接入简版与完整版图谱组件。
**交互口径:**
- 打开页签时优先使用模型摘要或人员对象中的身份证号/姓名自动查询。
- 专项排查版允许手工输入身份证号或员工姓名重新查询,并支持手工新增资金流向。
- 项目分析弹窗版保留图、基础节点详情、边汇总和轻量明细,不保留搜索、手工新增、疑似企业弹层和复杂操作。
- 默认展示 Top 20 资金边,避免一次渲染过多边影响交互。
- 一期不自动展开追溯层级,节点“一层展开”通过追加一圈节点和边 merge 回现有图谱。
**后续追溯口子:**
- 当前图谱节点已保留原始 `nodeKey``canExpand`
- 未来可在节点点击事件中调用后端追溯接口,把新增节点和边合并进现有图谱。
- 组件已按一层查询和边明细查询拆分,后续追溯不会影响“点边看流水”的核心链路。

View File

@@ -0,0 +1,21 @@
# 结果总览弹窗资金流向逐笔流水展示前端实施计划
## 需求范围
- 修改结果总览“查看详情”弹窗中的“资金流向”页签。
- 去掉资金边详情中的“弹窗速览”提示文案。
- 在弹窗资金边详情中展示逐笔流水明细,并保持分页加载。
- 不修改专项排查页的图谱入口和完整图谱展示逻辑。
## 实施方案
1. 调整 `ProjectAnalysisFundFlowTab.vue` 中传给 `FundGraphSection` 的参数,开启资金边逐笔流水表格。
2. 保持 `FundGraphSection` 现有边明细接口调用逻辑不变,继续使用分页查询。
3. 在弹窗包装组件内改为上方图谱、下方逐笔流水布局,收敛表格和分页样式,避免逐笔流水表撑高或挤压图谱画布。
4. 不新增接口、不修改后端、不改变专项排查页完整下钻能力。
## 验证计划
- 前端构建前按项目规则执行 `nvm use` 并确认 Node 版本。
- 执行前端构建或聚焦测试,确认组件编译通过。
- 使用真实页面打开结果总览“查看详情”弹窗,切换到“资金流向”,点击资金边确认下方显示逐笔流水和分页。

View File

@@ -0,0 +1,36 @@
# 结果总览弹窗资金流向可用性优化前端实施计划
## 目标
- 优化结果总览“查看详情”弹窗内“资金流向”页签的图谱展示空间。
- 降低多条资金边金额标签、节点名称在小画布中重叠的问题。
- 在查看单个对手方资金边明细后,通过点击图谱画布空白区域恢复全量图谱状态。
## 实施范围
- 仅调整结果总览“查看详情”弹窗中的资金流向图谱。
- 不调整专项排查页资金图谱的默认尺寸和业务逻辑。
- 不修改后端接口、数据库和资金流水分页接口。
## 实施方案
1.`ProjectAnalysisFundFlowTab` 中扩大资金流向工作区尺寸:
- 提高弹窗内图谱卡片高度。
- 改为上方图谱、下方逐笔流水布局,给图谱保留更大画布空间。
- 下方逐笔流水表格保持固定高度和分页展示,避免撑高弹窗。
2.`FundGraphSection` 中增加弹窗可配置能力:
- 增加边标签紧凑展示开关,金额使用“万/亿”等短格式展示。
- 支持隐藏资金边汇总卡片,只保留逐笔流水明细。
- 点击图谱画布空白区域时清空选中节点、选中边和逐笔流水明细,并重新渲染图谱。
3. 优化选中状态表达:
- 图谱中当前边和两端节点保持高亮,其他边降低透明度。
- 点空白区域后恢复初始全量图谱状态,不额外增加按钮。
## 验证计划
- 执行前端构建,确认无编译错误。
- 在真实页面进入结果总览“查看详情”弹窗,切换到“资金流向”。
- 选择包含多笔交易金额标签的资金边,验证节点名称和金额标签不再严重叠加,逐笔流水显示在图谱下方。
- 点击图谱画布空白区域,验证逐笔流水清空,图谱恢复全量状态。

View File

@@ -0,0 +1,609 @@
# 图谱开发决策记录
记录当前已确认的资金流图谱和关系图谱开发口径,作为后续开发、验收和跨对话延续的依据。
## 1. 页面嵌入位置
- 图谱功能先嵌入项目详情页的“专项排查”页签。
- 现有前端入口为 `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- 页面在项目详情内承载,但资金流图谱本身不按 `project_id` 过滤。
- 查询入口以全局身份证号 `cret_no` 或员工姓名为准。
## 2. 图谱表结构原则
- 建表逻辑尽量保持图谱平台已验证过的 SQL 逻辑。
- 不重新设计统一点表/边表。
- 表名保留图谱平台 SQL 中的五张结果表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_sum_edge`
- 不在五张图谱表中增加 `project_id` 作为查询过滤口径。
- 一条流水可能存在于多个项目中,资金流图谱按全局资金关系构建,避免项目维度导致重复建点或重复算边。
## 3. 五张表职责
| 表名 | 作用 |
| --- | --- |
| `lx_fund_flow_subject_node` | 主体点,表示人员、企业、名称代理主体 |
| `lx_fund_flow_account_node` | 账户点,表示具体账号或名称代理账户 |
| `lx_fund_flow_own_account_edge` | 主体到账户的持有关系 |
| `lx_fund_flow_detail_edge` | 账户层逐笔资金交易边 |
| `lx_fund_flow_sum_edge` | 主体层资金汇总边,前端默认展示 |
关键点:
- 一个人可能有多个账号,所以必须保留主体点、账户点、持有边三层结构。
- 前端默认展示主体层汇总边,不默认展示全部账户层明细边,避免节点过多。
- 点击汇总边后,再查询账户层逐笔流水。
## 4. 构建逻辑
构建逻辑以 `tupu/资金流图谱代码/lanxi_liushui_no_relation_simplified.sql` 为主。
重要口径:
- 项目内 SQL 尽量和图谱平台原 SQL 保持一致。
- 先导入一部分“所有员工流水明细”作为图谱基座。
- 这部分基座数据视为已验证、绝对正确,不应被后续构建流程随意清空或覆盖。
- 后续从 `ccdi_bank_statement` 拉取新增流水时,需要先和图谱基座做一致性判断。
- 如果 `ccdi_bank_statement` 中的流水已经能在图谱基座中匹配到一致流水,则不同步进图谱,避免重复计入。
- 如果没有匹配到一致流水,则按原图谱 SQL 逻辑增量插入图谱。
保留的核心口径:
- 本方证件号 `cret_no` 必须存在。
- 对手方名称必须存在,空值、空串、`0` 过滤。
- 金额必须有效,支出和收入统一成 `amount``flag`
- 支出 `flag = 1`,收入 `flag = 2`
- 明细去重,避免重复流水导致金额和笔数翻倍。
- 同名归并只作用于主体层,不改变账户节点和账户明细边。
- 无账号但有名称的对手方,按原 SQL 逻辑生成名称代理账户和名称代理主体。
一期先不展开追溯能力。需要为后续追溯预留字段和逻辑口子:
- `source_table`
- `penetrate_level`
- 后续可扩展 `FIRST``LEVEL1` 等来源。
## 4.1 基座与增量同步口径
图谱表不是“清空重建”的临时结果表,而是承载已验证图谱基座和后续增量的正式结果表。
基座数据:
- 来源为先导入的所有员工流水明细。
- 按原图谱平台 SQL 生成五张 `lx_*` 表。
- 基座数据作为可信结果保留。
增量数据:
- 来源为后续 `ccdi_bank_statement`
- 拉取后先按原 SQL 的流水标准化和去重口径生成候选流水。
- 候选流水和既有 `lx_fund_flow_detail_edge` 做一致性比对。
- 已存在一致流水时跳过,不插入图谱。
- 不存在一致流水时,再增量生成账户点、主体点、持有边、明细边和汇总边。
一致性比对建议使用稳定业务特征,而不是 `project_id`
- 本方账号
- 本方户名
- 对手方账号
- 对手方户名
- 交易日期
- 金额
- 收支方向 `flag`
- 摘要 `user_memo`
- 银行流水号或交易流水号,如果有
增量插入要求:
- 点表按 `object_key` 去重插入。
- 持有边按 `object_key` 去重插入。
- 明细边先判重,未存在才插入。
- 汇总边需要按主体对和方向重新聚合或局部 upsert不能简单追加导致金额翻倍。
## 4.2 ODPS 基座同步到 MySQL
当前真实部署口径:
- 原图谱 SQL 已在 ODPS 中有一份结果。
- ODPS 结果只涉及行内流水。
- ODPS 已经产出五张图谱结果表。
- 可以先将 ODPS 中五张结果表一次性同步到纪检 MySQL。
- MySQL 同步建表脚本记录在 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- 生产数据库表结构变更由人工单独执行,不跟随测试环境或应用发布自动更新。
- 项目内保留 SQL 文件,用于本地验证、评审和生产手动执行参考。
同步后的 MySQL 五张表继续使用原图谱表名:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_sum_edge`
同步要求:
- ODPS 到 MySQL 首次同步只做基座装载。
- 基座装载完成后,后续不再通过清空五张表重建处理。
- ODPS 字段和 MySQL 字段同名的按显式字段列表导入。
- MySQL 侧新增字段如 `family_relation_type``summary_object_key``source_table``penetrate_level``bank_statement_id``bank_trx_number` 可为空。
- `lx_fund_flow_sum_edge.detail_ids` 在 MySQL 中使用 `LONGTEXT` 接收 ODPS ARRAY 同步后的 JSON 或字符串表示,前后端查询不强依赖该字段。
## 4.3 MySQL 后续增量方式
后续新增数据都在纪检 MySQL 内处理:
- 来源表为 `ccdi_bank_statement`
- 增量逻辑从 `ccdi_bank_statement` 抽取候选流水。
- 候选流水按原图谱 SQL 口径标准化、过滤、生成 object_key。
- 先和既有 `lx_fund_flow_detail_edge` 做一致性判重。
- 已存在一致流水时不插入图谱。
- 不存在一致流水时,才增量插入点、账户、持有边、明细边。
- 汇总边 `lx_fund_flow_sum_edge` 按主体对和方向重新聚合或局部 upsert。
调度建议:
- 一期建议做每日定时调度,不建议一开始做实时。
- 推荐使用 RuoYi/Quartz 定时任务,每天凌晨或低峰期执行。
- 同时保留后台手动触发能力,便于首次补跑、排查和修复。
- 实时同步不是不能做,但没有必要优先做;实时会增加事务、锁、重复判断和汇总边更新复杂度。
推荐执行节奏:
1. ODPS 五张结果表一次性同步到 MySQL。
2. MySQL 跑一次家庭关系补充和 `summary_object_key` 回填。
3. 每日 Quartz 调度从 `ccdi_bank_statement` 抽取新增候选流水。
4. 候选流水与 `lx_fund_flow_detail_edge` 判重。
5. 未命中重复的流水增量入图。
6. 更新对应主体层汇总边。
7. 前端始终只基于 MySQL 五张 `lx_*` 图谱表查询展示。
## 4.4 数据库变更执行边界
数据库表结构改动属于生产库手工变更,不纳入测试环境自动更新。
后续开发分工:
- SQL 文件由代码库保留,作为生产手工执行依据和本地验证依据。
- 生产执行由人工确认后单独处理。
- 后端开发默认这些表在目标库中已经存在。
- 前端开发不感知数据库变更,只调用后端接口。
- 测试环境如没有这五张表,需要手动执行 SQL 后再联调。
- 应用发布包不自动执行这些 DDL。
## 5. 家庭关系
家庭关系是本次项目内新增能力,参考 `tupu/资金流图谱代码/资金流图谱_家庭关系补充.sql`
处理原则:
- 不改变资金方向。
- 不改变主体归并逻辑。
- 不改变账户层明细边生成逻辑。
- 只在资金边上增加家庭关系标注。
匹配规则:
- 交易任意一侧可映射到员工主体。
- 员工主体必须有身份证号。
- 对手方姓名命中 `ccdi_staff_fmy_relation.relation_name`
- 同一员工和同一关系人姓名只有一个 `relation_type` 时才标注。
- 多个关系类型冲突时不打标,避免误判。
建议补充字段:
- `lx_fund_flow_detail_edge.family_relation_type`
- `lx_fund_flow_sum_edge.family_relation_type`
## 6. 查询逻辑
搜索主体:
```sql
select *
from lx_fund_flow_subject_node
where idnocfno = #{keyword}
or name like concat('%', #{keyword}, '%');
```
查询主体层资金图:
```sql
select *
from lx_fund_flow_sum_edge
where from_key = concat('idno_node/', #{objectKey})
or to_key = concat('idno_node/', #{objectKey})
order by amount desc, total_trans_cnt desc
limit #{limit};
```
点击汇总边查询逐笔流水:
```sql
select *
from lx_fund_flow_detail_edge
where summary_object_key = #{sumObjectKey}
order by trx_date desc
limit #{offset}, #{pageSize};
```
说明:
- 原图谱平台 `detail_ids` 可以保留。
- MySQL 分页查询建议增加 `summary_object_key`,用于从汇总边直接查明细边。
- `summary_object_key` 是查询优化字段,不改变原图谱平台点边模型。
## 7. 前后端开发边界
后端负责:
- 从五张 `lx_*` 图谱结果表读取数据。
- 按身份证号或姓名定位主体。
- 返回主体层图谱节点和汇总边。
- 支持点击资金汇总边分页查询逐笔流水。
- 透出家庭关系字段。
- 空表或脏数据时返回空结果,不让前端报错。
前端负责:
- 在“专项排查”页签中呈现图谱展示区域。
- 支持身份证号或员工姓名搜索。
- 支持“资金图谱 / 关系图谱”页签。
- 一期资金图谱做实,关系图谱可先保留入口。
- 默认展示主体层资金汇总图。
- 点击边展示逐笔流水明细。
- 展示图谱明细边中已写入的家庭关系标签,如配偶、父母、子女。
## 8. 基座保护与异常数据处理
图谱表承载已验证基座,不按“可随意清空重建”设计。人工可以维护或清理异常数据,但默认应保护已有基座。
处理要求:
- 后续增量同步不得清空五张 `lx_*` 表。
- 增量同步前必须先判断候选流水是否已存在于 `lx_fund_flow_detail_edge`
- 已存在一致流水时跳过,避免重复金额和重复笔数。
- 人工清理异常边后,后端查询需要能容忍局部缺失数据。
- 边表有、点表缺失时,后端过滤无法匹配节点的边,不让前端报错。
- 明细边为空时,点击汇总边提示暂无逐笔流水。
- 如果人工确实清理了部分图谱数据,后续增量插入仍需按 `object_key` 和流水一致性规则防重。
## 9. UI 风格
固定设计口径:
- 浅色系统风格。
- 正式后台质感。
- 与当前纪检系统色调保持一致,蓝、白、灰为主。
- 朝图谱平台式交互靠齐。
- 不做黑色大屏。
- 不做网感、霓虹、炫光科技风。
- 图谱画布使用浅灰白背景,边界清楚。
- 搜索区放在顶部,支持身份证号和姓名。
- 页签为“资金图谱”和“关系图谱”。
- 明细使用右侧抽屉或下方面板展示,优先保证字段清楚和分页性能。
## 10. 后续开发顺序
建议顺序:
1. 先落项目内 SQLDDL、构建 SQL、家庭关系补充 SQL、索引 SQL。
2. 先支持已导入员工流水明细作为图谱基座。
3. 增加 `ccdi_bank_statement` 到图谱表的增量同步逻辑。
4. 增量同步必须先和既有 `lx_fund_flow_detail_edge` 做一致性判重,已存在则不同步。
5. 后端接口改为读取五张 `lx_*` 图谱表。
6. 前端在“专项排查”页签接入图谱展示区域。
7. 完成资金图谱搜索、展示、点击边查明细。
8. 增加家庭关系标签展示。
9. 验证基座保护、增量防重、一个人多个账号、家庭关系命中等场景。
## 11. 当前代码进度与偏差
截至 2026-05-28项目内已经做过一版一期资金流图谱代码但这版实现口径与当前最终方案不完全一致后续需要重构而不是直接当最终版。
已完成过的代码:
- 后端新增 `CcdiFundGraphController`,接口路径为 `/ccdi/project/fund-graph/graph``/ccdi/project/fund-graph/edge-detail`
- 后端新增 DTO/VO`CcdiFundGraphQueryDTO``CcdiFundGraphEdgeDetailQueryDTO``CcdiFundGraphVO``CcdiFundGraphNodeVO``CcdiFundGraphEdgeVO``CcdiFundGraphStatementVO`
- 后端新增 Mapper 和 Service`CcdiFundGraphMapper``ICcdiFundGraphService``CcdiFundGraphServiceImpl``CcdiFundGraphMapper.xml`
- 前端新增接口文件 `ruoyi-ui/src/api/ccdi/fundGraph.js`
- 前端新增组件 `ProjectAnalysisFundFlowTab.vue`
- 前端已在 `ProjectAnalysisDialog.vue` 中接入资金流图谱页签。
旧版已验证情况:
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- `npm run build:prod` 通过。
- 真实库只读校验过项目 33 和姓名样例,能查出资金边,点击边能查逐笔流水。
- 曾修正 MySQL 8 保留词别名 `rows` 和字符集排序规则不一致问题。
当前偏差:
- 旧版接口是实时从 `ccdi_bank_statement` 聚合资金图谱,不读取五张 `lx_*` 图谱结果表。
- 旧版查询带项目上下文,当前最终口径是不按 `project_id` 过滤,以全局 `cret_no` 或姓名为入口。
- 旧版前端接在项目分析弹窗 `ProjectAnalysisDialog`,当前最终入口应放在“专项排查”页签。
- 旧版没有按图谱平台五表基座和增量同步口径处理。
- 旧版没有家庭关系标注。
后续处理原则:
- 可复用旧版的图谱展示、点击边查明细、分页表格等前端交互经验。
- 可复用旧版 DTO/VO 中适合前端展示的字段,但字段来源需要改为五张 `lx_*` 表。
- 后端 SQL 必须从实时聚合 `ccdi_bank_statement` 改为读取 `lx_fund_flow_subject_node``lx_fund_flow_account_node``lx_fund_flow_own_account_edge``lx_fund_flow_detail_edge``lx_fund_flow_sum_edge`
- 前端入口需要从项目分析弹窗迁移或重做到“专项排查”页签。
- 旧版文件在重构时应谨慎处理,避免影响当前项目分析弹窗已有功能。
## 12. 页面查询与汇总表最新决策
最新决策:
- 纪检平台资金流图谱页面不强依赖 `lx_fund_flow_sum_edge`
- 页面查询以 `lx_fund_flow_detail_edge` 为事实表,由后端按当前查询条件实时聚合。
- 前端不做金额和笔数聚合,只负责渲染后端返回的节点、边和明细。
- `lx_fund_flow_sum_edge` 如生产侧不需要兼容图谱平台页面,可以不作为纪检页面必需表。
- 如果 ODPS 已有 `lx_fund_flow_sum_edge`,可以选择不同步到 MySQL或同步后仅作为参考缓存不作为纪检页面查询依据。
原因:
- 用户每次查询通常以一个人为中心,一跳图谱范围可控。
- 用户需要按 `trx_date` 任意筛选时间范围。
- 全量 `lx_fund_flow_sum_edge` 不能准确表达任意时间段内的金额和笔数。
- 每天新增明细后维护汇总表会增加复杂度。
- 后端从明细边实时聚合能保证筛选结果准确,且比前端聚合更可靠。
后端聚合口径:
1. 用身份证号、姓名或 `object_key` 定位主体点。
2. 查询该主体名下账户。
3.`lx_fund_flow_detail_edge` 查询这些账户相关流水。
4.`trx_date`、金额、方向、家庭关系等筛选条件过滤。
5. 后端将账户层明细边聚合为主体层资金边。
6. 返回前端用于图谱展示。
时间筛选字段:
- 所有时间筛选基于 `lx_fund_flow_detail_edge.trx_date`
- 不用 `lx_fund_flow_sum_edge.first_trx_date``lastest_trx_date` 判断筛选结果。
## 13. 节点穿透最新决策
节点穿透以 `lx_fund_flow_subject_node.object_key` 为唯一标识,不按姓名穿透。
口径:
- 实名主体按原 SQL 逻辑生成 `object_key`,即 `md5(trim(idnocfno))`
- 用户点击节点后,可选择“以此节点为中心查询”或“展开此节点”。
- 默认不自动穿透,避免图谱过长和误展开。
- 后端按被选节点的 `object_key` 查询其账户和流水。
- 节点是否可穿透由后端返回 `canExpand` 控制。
允许穿透:
- 有明确身份证号或证件号的实名主体。
- 有明确账户归属、能通过 `lx_fund_flow_own_account_edge` 找到账户的主体。
默认不穿透:
- 只有名称、没有证件号、没有明确账户归属的名称代理主体。
- 无法通过 `object_key` 准确定位账户集合的节点。
交互建议:
- 一期做“设为中心查询”,即点击节点后重新以该节点为中心画一跳图。
- 后续再做“在当前图上追加展开”,避免一期图谱状态管理过复杂。
## 14. 最终减法版决策
本节覆盖前文早期关于五张表、`lx_fund_flow_sum_edge`、关系图谱页签的旧设想。后续开发以本节为准。
本节为 2026-05-28 资金流图谱减法版决策,重点是不依赖 `lx_fund_flow_sum_edge`。关于关系图谱页签,后续已在第 16 节更新为“保留关系图谱能力”,以第 16 节为准。
必要表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
不依赖:
- `lx_fund_flow_sum_edge`
页面查询:
- 输入身份证号、姓名或点击节点 `object_key`
- 后端定位主体点。
- 后端查询主体持有账户。
- 后端从 `lx_fund_flow_detail_edge` 按账户、`trx_date`、金额等条件实时聚合资金边。
- 前端只渲染后端返回的资金节点、资金边和逐笔明细。
家庭关系:
- 只作为资金流图谱中的标签展示。
- 家庭关系识别在图谱构建或数据加工阶段完成,并写入 `lx_fund_flow_detail_edge.family_relation_type`;后端查询接口只读取并返回该字段,不实时匹配家庭表。
- 如果生产构建需要按对手方户名匹配 `ccdi_staff_fmy_relation.relation_name`,应在构建 SQL 中完成,并控制同名误判风险。
- 有明确 `relation_cert_no` 的家庭关系人按实名主体处理,`object_key = md5(trim(relation_cert_no))`
- 用户点击该节点时,可按该节点 `object_key` 设为中心继续查询。
测试数据:
- 测试 DDL 为 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- 测试数据脚本为 `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`
- 测试数据只写四张必要表。
- 测试数据来源于 dev 库 `ccdi_bank_statement``ccdi_staff_fmy_relation`
- 原始流水表不被修改。
## 15. 前后端与页面交互设计
默认查询:
- 默认查询全部流水,不默认带交易日期过滤。
- 用户选择交易日期后,后端才按 `lx_fund_flow_detail_edge.trx_date` 过滤。
- 时间过滤不查汇总表,直接从明细边实时聚合。
后端接口建议:
```text
GET /ccdi/project/fund-graph/search
GET /ccdi/project/fund-graph/graph
GET /ccdi/project/fund-graph/edge-detail
POST /ccdi/project/fund-graph/manual-edge
GET /ccdi/project/relation-graph/search
GET /ccdi/project/relation-graph/graph
GET /ccdi/project/relation-graph/suspected-enterprises
```
接口职责:
- `search`:按身份证号或姓名查主体点,返回候选主体列表。
- `graph`:按主体 `object_key` 查询一跳资金图,默认全部流水,可选日期、金额、方向过滤。
- `edge-detail`:点击资金边后,分页查询该边下的逐笔流水。
`graph` 入参:
```text
objectKey 必填,主体 object_key
startDate 可选,交易开始日期
endDate 可选,交易结束日期
amountMin 可选,最小金额
amountMax 可选,最大金额
direction 可选1支出、2收入
limit 可选默认20
```
`graph` 返回:
```text
centerNode 当前中心主体
nodes 图谱节点
edges 聚合后的资金边
summary 当前查询范围的总金额、总笔数、家庭关系边数量
```
节点字段:
```text
objectKey
nodeKey idno_node/{object_key}
name
idNo
nodeType PERSON / PROXY
canExpand
relationType 如果是家庭关系节点,返回配偶/父亲/母亲等
```
边字段:
```text
edgeKey
fromKey
toKey
fromName
toName
direction 1支出、2收入
amount
transactionCount
firstTrxDate
lastTrxDate
familyRelationType
```
`edge-detail` 入参:
```text
fromObjectKey
toObjectKey
direction
startDate
endDate
pageNum
pageSize
```
页面交互:
- 页面位置:项目详情“专项排查”页签。
- 页面标题:图谱展示。
- 展示“资金流图谱”和“关系图谱”两个页签。
- 搜索区只保留必要控件:身份证号/姓名、交易日期、查询、重置。
- 默认空态提示:请输入身份证号或姓名查询资金流图谱。
- 查询后画一跳图,中心节点为当前人员。
- 边上只显示金额和笔数,家庭关系用标签显示。
- 点击资金边,右侧抽屉展示逐笔流水。
- 点击节点,提供“设为中心查询”;默认不自动穿透。
- 只有 `canExpand = true` 的节点展示“设为中心查询”。
性能口径:
- 前端不聚合金额和笔数。
- 后端只围绕一个主体的账户集合查明细边。
- 默认全部流水也只查当前主体相关边,不扫全表。
- 必须使用 `lx_fund_flow_own_account_edge.from_key``lx_fund_flow_detail_edge.from_key/trx_date``lx_fund_flow_detail_edge.to_key/trx_date` 索引。
- 后端 SQL 参数比较需要显式 `COLLATE utf8mb4_general_ci`,避免当前库连接排序规则和表排序规则不一致。
当前 dev 测试数据:
```text
测试身份证号617673198109148314
测试 object_key以 `MD5('617673198109148314')` 为准
主体点10
账户点14
持有边14
明细边72
```
测试覆盖:
- 默认全部流水聚合。
- 日期范围筛选聚合。
- 支出方向 `flag = 1`
- 收入方向 `flag = 2`
- 家庭关系标签:配偶、父亲、母亲。
- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。
- 点击家庭关系节点按 `object_key` 设为中心查询。
## 16. 2026-05-29 最新验收口径
本节为当前最新口径,用于覆盖前文早期变更记录中的冲突描述。
当前图谱功能保留两类能力:
- 资金流图谱:作为专项排查中的核心图谱能力,读取 `lx_fund_flow_subject_node``lx_fund_flow_account_node``lx_fund_flow_own_account_edge``lx_fund_flow_detail_edge`,并叠加 `lx_fund_flow_manual_edge` 手工资金流向。
- 关系图谱:保留页面页签和接口能力,读取 `lx_rel_node``lx_rel_family_edge``lx_rel_stock_edge``lx_rel_represent_edge`,支持家庭关系、股东持股、法定代表人关系和疑似同名企业召回。
当前页面入口:
- 项目详情“专项排查”页签展示完整图谱工作台,包含“资金流图谱”和“关系图谱”两个页签。
- 项目分析弹窗“资金流向”页签展示简版资金流图谱。
- 项目分析弹窗“关系图谱”页签展示简版关系图谱。
当前接口入口:
```text
GET /ccdi/project/fund-graph/search
GET /ccdi/project/fund-graph/graph
GET /ccdi/project/fund-graph/edge-detail
POST /ccdi/project/fund-graph/manual-edge
GET /ccdi/project/relation-graph/search
GET /ccdi/project/relation-graph/graph
GET /ccdi/project/relation-graph/suspected-enterprises
```
当前数据库执行口径:
- 新环境可参考 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql``sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`
- 已建资金流图谱表的环境使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引,不删除、不重建、不清空基座数据。
- 已建关系图谱表的环境使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑补字段和补索引。
- 生产 DDL 和补充 SQL 都由人工确认后手动执行,不随应用发布自动执行。
当前验收样例:
```text
资金流图谱测试身份证号617673198109148314
关系图谱测试身份证号330101198001010011
```

View File

@@ -0,0 +1,98 @@
# 图谱生产数据库手工变更清单
本清单只记录资金流图谱涉及的生产数据库表结构和数据准备事项。该部分由人工在生产库手动执行,不随应用发布自动执行,也不要求测试环境自动更新。
## 1. DDL 脚本
生产建表脚本:
```text
sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql
```
当前减法版创建五张资金流图谱必要表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
不创建、不依赖 `lx_fund_flow_sum_edge`。资金图谱页面由后端基于 `lx_fund_flow_detail_edge.trx_date` 按当前查询条件实时聚合真实资金边,手工资金流向汇总边单独存入 `lx_fund_flow_manual_edge`
## 2. 生产执行边界
- 生产库 DDL 由人工手动执行。
- 应用发布包不自动执行 DDL。
- 测试环境不会自动同步这些变更。
- 代码库保留 SQL 文件,只作为生产执行、评审和本地验证依据。
- 后端开发默认目标库中上述 `lx_*` 表已存在。
## 3. ODPS 基座同步
生产建表后,先从 ODPS 同步已验证的资金流图谱基座到 MySQL。
同步来源:
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_subject_node`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_account_node`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_own_account_edge`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_detail_edge`
同步原则:
- ODPS 基座是已验证数据,作为 MySQL 图谱基座保留。
- 同步时建议使用显式字段列表,不依赖 `select *`
- MySQL 侧新增字段允许为空。
- `lx_fund_flow_sum_edge` 不作为纪检资金图谱页面必要表,可不从 ODPS 同步。
- `lx_fund_flow_manual_edge` 不从 ODPS 同步,生产建表后初始为空,由纪检平台手工分析功能写入。
## 4. 后续增量
ODPS 基座同步后,后续新增流水在纪检 MySQL 内处理。
增量来源:
- `ccdi_bank_statement`
增量原则:
- 先标准化候选流水。
- 再和既有 `lx_fund_flow_detail_edge` 做一致性判重。
- 已存在一致流水,不同步进图谱。
- 不存在一致流水,才增量插入主体点、账户点、持有边、明细边。
- 不维护汇总表;页面查询时实时聚合。
## 5. 调度建议
一期建议采用每日定时任务,不建议一开始做实时。
推荐方式:
- RuoYi/Quartz 定时任务。
- 每日低峰期执行。
- 保留手动触发能力,用于补跑、排查和修复。
## 6. 前后端依赖
前后端开发依赖上述 MySQL 图谱表的查询结果。
- 前端不直接访问数据库。
- 后端接口读取 `lx_*` 表。
- 页面入口放在项目详情的“专项排查”页签。
- 资金流图谱中真实资金边基于 `lx_fund_flow_detail_edge` 实时聚合。
- 手工资金边来自 `lx_fund_flow_manual_edge`,属于主体级汇总边,只存 `from_object_key``to_object_key`,不存冗余 `from_key``to_key`;图谱展示时由后端临时拼出 `idno_node/{object_key}`,不提供逐笔流水下钻。
- 查询按全局 `cret_no`、姓名或节点 `object_key`,不按 `project_id` 过滤。
## 7. 性能和索引口径
一期资金图谱默认只查一个中心主体的一层资金边,并设置 `minTotalAmount = 1000``limit = 20`,不会默认拉全量毛刺边。
生产索引重点:
- `lx_fund_flow_subject_node``PRIMARY KEY(object_key)``idx_lx_fund_flow_subject_idnocfno(idnocfno)``idx_lx_fund_flow_subject_name(name)`
- `lx_fund_flow_own_account_edge``idx_lx_fund_flow_own_from_key(from_key)``idx_lx_fund_flow_own_to_key(to_key)`
- `lx_fund_flow_detail_edge``idx_lx_fund_flow_detail_from_date(from_key, trx_date)``idx_lx_fund_flow_detail_to_date(to_key, trx_date)``idx_lx_fund_flow_detail_from_to(from_key, to_key)`
- `lx_fund_flow_manual_edge``idx_lx_fund_flow_manual_from(from_object_key)``idx_lx_fund_flow_manual_to(to_object_key)``idx_lx_fund_flow_manual_pair_direction(from_object_key, to_object_key, direction)`
如果后续单个主体关联流水达到几十万级,再考虑增加主体级冗余字段或月度汇总表;一期不建 `sum_edge`

View File

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

View File

@@ -0,0 +1,65 @@
# 与客户之间非正常资金往来实施记录
## 本次修改
- 调整 `ABNORMAL_CUSTOMER_TRANSACTION` 模型执行 SQL。
- 调整“涉疑交易明细 -> 名单库命中” SQL。
- 调整涉疑交易明细前端标签展示。
- 补充 SQL 口径测试,不改表结构。
## 模型 SQL 口径
- 位置:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 主体范围:员工本人、`ccdi_staff_fmy_relation.status = 1` 的有效亲属。
- 金额口径:`GREATEST(IFNULL(amount_dr, 0), IFNULL(amount_cr, 0)) > 1000`
- 信贷客户命中:对手方账号命中 `ccdi_account_info.owner_type = 'CREDIT_CUSTOMER'`
- 中介账号命中:对手方账号命中 `ccdi_account_info.owner_type = 'INTERMEDIARY'`
- 中介企业命中:对手方名称精确命中 `ccdi_enterprise_base_info.enterprise_name`,且企业来源为中介。
- 中介人员精确命中:对手方名称精确命中 `ccdi_biz_intermediary.name`
- 中介人员模糊命中:对手方名称包含 `ccdi_biz_intermediary.name`,仅限 `bank in ('ALIPAY', 'WECHAT')`
- 明确不再使用对手方证件号、对手方统一社会信用代码命中。
## 名单库命中口径
- 位置:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- 信贷客户账号命中时,`nameListHitType` 返回“信贷客户”。
- 中介账号、人员名称、企业名称命中时,`nameListHitType` 返回“中介”。
- 名单库命中同样要求交易金额大于 1000。
- 涉疑交易明细和 PDF 导出按 `bank_statement_id` 聚合,避免同一流水因“模型规则命中”和“名单库命中”重复展示。
## 前端展示
- 位置:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ABNORMAL_CUSTOMER_TRANSACTION` 在异常标签中优先展示。
- 如果同一流水名单库类型为“中介”,该标签展示为“疑似与中介往来”。
- 如果同一流水名单库类型为“信贷客户”,该标签展示为“与信贷客户之间非正常资金往来”。
- 如果没有名单库类型,兜底展示为“与客户之间非正常资金往来”。
- 不再把“中介/信贷客户”追加成额外异常标签,避免重复展示。
## 报告展示
- 结果总览 PDF 报告中的涉疑交易明细表同步使用上述标签展示口径。
- 同一条流水只替换 `ABNORMAL_CUSTOMER_TRANSACTION` 的展示文案,不额外追加“中介/信贷客户”标签。
## 影响文件
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectSuspiciousTransactionItemVO.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `docs/reports/implementation/2026-05-21-abnormal-customer-transaction-implementation.md`
## 验证情况
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiProjectOverviewMapperSqlTest" test` 通过21 个测试。
- 测试覆盖的伪造场景口径:
- 信贷客户账号命中。
- 中介账号命中。
- 中介人员名称精确命中。
- 中介人员名称模糊命中仅限 `ALIPAY/WECHAT`
- 名单库命中金额门槛大于 1000。
- 涉疑交易明细按 `bank_statement_id` 聚合去重。
- 涉疑交易明细和 PDF 报告按名单库类型细分展示异常标签,且不重复展示。

View File

@@ -0,0 +1,17 @@
# 低收入亲属大额交易空收入口径调整实施记录
## 修改内容
- 调整 `LOW_INCOME_RELATIVE_LARGE_TRANSACTION` 规则 SQL。
- 关系人 `annual_income is null` 时不再进入低收入候选,不触发该规则。
- 仅当 `annual_income` 明确有值,且满足 `annual_income = 0``annual_income / 12 < 3000` 时,才进入低收入候选。
## 影响范围
- 后端银行流水打标规则:低收入亲属大额交易。
- 不涉及前端展示、接口入参、数据库表结构。
## 验证
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest,CcdiBankTagRuleSqlMetadataTest,BankTagRuleConfigResolverTest" test` 通过,共 43 个测试。

View File

@@ -0,0 +1,63 @@
# dev-ui 本次提交清单
## 提交信息
- 分支:`dev-ui`
- 提交说明:`优化涉疑交易模型口径和报告展示`
## 功能清单
1. 涉疑交易模型口径调整
- `ABNORMAL_CUSTOMER_TRANSACTION` 补充信贷客户账号命中。
- `ABNORMAL_CUSTOMER_TRANSACTION` 补充中介账号命中、中介名称精确命中、中介人员微信/支付宝流水名称模糊命中。
- 名单库命中保留中介和信贷客户两类。
- 金额门槛统一按单边流水金额大于 1000 元判断。
- 低收入亲属大额交易排除年收入为空的亲属;年收入为空不主动预警。
- 大额单笔收入、疑似兼职相关收入预警排除公积金中心收入。
2. 涉疑交易明细展示与导出
- 涉疑交易明细保留“名单库命中”和“模型规则命中”筛选。
- 同一条流水同时命中名单库和模型规则时,按 `bank_statement_id` 聚合去重,不重复展示。
- 名单库命中类型区分为“中介”和“信贷客户”。
- 前端异常标签按名单类型展示:
- 中介:`疑似与中介往来`
- 信贷客户:`与信贷客户之间非正常资金往来`
- 未命中名单类型时:`与客户之间非正常资金往来`
- PDF 导出复用去重后的明细逻辑。
3. 拉取本行流水弹窗
- 证件号码输入提示改为仅支持英文逗号分隔。
- 日期默认开始时间为昨天往前一年,结束时间为昨天。
- 可选日期范围限制为 2025-01-01 到昨天。
4. 报告导出展示
- “一键导出”按钮文案改为“导出报告”。
- PDF 报告章节标题错行问题修正。
- PDF 报告标题加粗展示。
## 涉及文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectSuspiciousTransactionItemVO.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporterTest.java`
## 验证情况
- 已执行后端 Mapper SQL 断言测试。
- 已执行 PDF 导出样式断言测试。
- 已执行前端生产构建。
- 已在本地页面验证涉疑交易明细中“中介/信贷客户”命中展示不重复。
- 已验证 PDF 导出包含“疑似与中介往来”和“与信贷客户之间非正常资金往来”。

View File

@@ -0,0 +1,24 @@
# 拉取本行流水弹窗交互调整实施记录
## 修改内容
- 将证件号码输入提示调整为“仅支持英文逗号分隔”。
- 证件号码解析仅按英文逗号分隔,不再支持中文逗号、顿号、换行等分隔方式。
- 本行存量流水日期默认值调整为:
- 开始日期:昨天往前一年。
- 结束日期:昨天。
- 日期可选范围限制为 `2025-01-01` 到昨天。
## 影响范围
- 前端页面:
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- 不涉及后端接口、不改数据库。
## 验证情况
- 已在真实项目详情页打开“拉取本行流水”弹窗验证:
- 提示文案显示为仅支持英文逗号分隔。
- 默认开始日期为 `2025-05-25`
- 默认结束日期为 `2026-05-25`
- 当前系统日期为 `2026-05-26`,默认日期符合“开始默认一年前、结束默认昨天”。

View File

@@ -0,0 +1,20 @@
# 导出报告章节标题错行修复实施记录
## 修改内容
- 修复结果总览 PDF 导出报告中,表格结束后下一节标题可能压到表格底边框的问题。
- 原因是 PDFBox 绘制文本时 `y` 坐标为文字基线,表格后仅保留 `8F` 间距15 号章节标题的字形会向上占用空间,导致视觉上与上一张表格边框重叠。
- 将表格后间距调整为 `32F`,确保下一节标题与上一张表格有明显稳定留白。
- 章节标题、子标题和封面标题改为加粗展示;正文、表格文字、颜色和列宽不调整。
## 影响范围
- 后端 PDF 报告导出:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java`
- 不涉及前端页面、不改接口、不改数据库。
## 验证情况
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiProjectOverviewReportPdfExporterTest" test` 通过2 个测试。
- 新增测试断言表格后间距必须大于章节标题字号,避免后续回退成过小间距。

View File

@@ -0,0 +1,75 @@
# 专项排查图谱实施记录
## 实施范围
- 当前口径保留资金流图谱和关系图谱两个页签。
- 页面嵌入项目详情“专项排查”页签。
- 后端不再实时聚合 `ccdi_bank_statement`
- 资金流图谱后端读取四张基础图谱表,并叠加手工资金流向表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
- 关系图谱后端读取四张关系图谱表:
- `lx_rel_node`
- `lx_rel_family_edge`
- `lx_rel_stock_edge`
- `lx_rel_represent_edge`
- 默认查询全部流水;用户选择日期时按 `lx_fund_flow_detail_edge.trx_date` 过滤。
- 点击资金边可分页查看逐笔流水。
- 点击可穿透节点可按 `object_key` 设为中心重新查询。
## 关键改动
- 后端 `CcdiFundGraphMapper.xml` 改为四表查询和实时聚合。
- 后端 `CcdiFundGraphServiceImpl` 支持 `objectKey` 查询、节点穿透和边明细查询。
- 后端新增 `/ccdi/project/fund-graph/search` 主体搜索接口。
- 前端新增 `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- 前端新增 `ruoyi-ui/src/api/ccdi/graph/relationGraph.js`
- 前端新增 `FundGraphSection.vue`,提供紧凑型图谱卡片和边明细抽屉。
- `SpecialCheck.vue` 中原“图谱外链展示”占位卡替换为图谱组件。
- `ProjectAnalysisDialog.vue` 中“资金流向”和“关系图谱”页签替换为简版图谱展示。
## 测试数据
测试数据脚本:
```text
sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql
```
dev 库测试数据:
```text
资金流图谱测试身份证号617673198109148314
关系图谱测试身份证号330101198001010011
主体点10
账户点14
持有边14
明细边72
```
覆盖场景:
- 默认全部流水聚合。
- 日期范围筛选聚合。
- 支出 `flag = 1`
- 收入 `flag = 2`
- 家庭关系标签:配偶、父亲、母亲。
- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。
- 家庭关系节点按 `object_key` 设为中心查询。
## 验证
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- `npm run build:prod` 通过。
- dev 库 SQL 校验通过:身份证号可定位主体,默认全部流水和日期筛选均能聚合出资金边。
## 注意
- 生产库 DDL 不随应用发布自动执行。
- 生产需人工执行 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql` 后再发布后端。
- 生产如已建资金流图谱旧表,优先执行 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引。
- `lx_fund_flow_sum_edge` 不作为当前纪检资金图谱页面依赖。
- 如果目标库未建四张 `lx_*` 表,后端接口会报表不存在。

View File

@@ -0,0 +1,26 @@
# 图谱评审问题修复实施记录
## 本次修改
- 修复资金流图谱手工新增边在参数缺失时直接抛出服务端异常的问题,改为返回明确业务错误信息。
- 修复资金流图谱“展开一层”可追溯判断固定按 `to` 侧判断的问题,改为按当前中心节点的对端主体判断是否可继续扩展。
- 调整资金流图谱边列表组装逻辑,将真实边与手工边合并后统一排序,再按同一 `limit` 截断,避免结果条数放大和排序口径不一致。
- 删除未使用的重复前端 API 文件,收敛资金流图谱接口引用入口。
## 影响范围
- 后端资金流图谱接口:
- `POST /ccdi/project/fund-graph/manual-edge`
- `GET /ccdi/project/fund-graph/graph`
- 前端资金流图谱 API 组织方式:
- 保留 `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- 删除未使用的 `ruoyi-ui/src/api/ccdi/fundGraph.js`
## 验证计划
- 后端执行 `mvn -pl ccdi-project -am -DskipTests compile`
- 前端进入 `ruoyi-ui` 后先执行 `nvm use`,再执行 `npm run build:prod`
- 真实页面验收重点:
- 手工新增资金流向缺少必填项时返回明确错误提示
- 资金边“展开一层”按钮在流入/流出两类边下判断更符合对端主体实际可扩展性
- 同一查询条件下真实边与手工边整体排序和数量上限一致

View File

@@ -0,0 +1,18 @@
# 资金流图谱流水查询报错修复实施记录
## 本次修改
- 修复资金流图谱构建节点列表时,对边两端主体二次回查未设置 `limit` 的问题。
- 该问题会使 MyBatis 生成 `LIMIT null`MySQL 报 `near 'null'`,进而导致资金流图谱查询或点击资金边后的流水明细下钻显示失败。
- 保留 Mapper 层默认 `LIMIT 20`,同时在服务层补齐默认上限,避免其他调用路径再次传入空分页上限。
## 影响范围
- 后端接口:`GET /ccdi/project/fund-graph/graph`
- 关联前端表现:资金流图谱加载和资金边流水明细查询
## 验证
- 执行后端编译,确认图谱模块通过编译。
- 重新打包并按项目脚本重启后端,使运行中的 jar 包含本次修复。
- 使用真实接口查询资金流图谱和资金边流水明细,确认不再出现 `LIMIT null` SQL 错误。

View File

@@ -0,0 +1,36 @@
# 图谱改动梳理与验收文档实施记录
## 本次修改
- 新增图谱功能改动梳理与验收清单:`docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- 新增本轮图谱验收执行记录:`docs/tests/records/2026-05-29-graph-acceptance-record.md`
- 新增资金流图谱已建表环境补充 SQL`sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`
- 明确已建表场景下的 SQL 口径:保留 `CREATE TABLE IF NOT EXISTS` 作为新环境参考,已建表只做字段、索引、字符集、排序规则核对和补充。
- 明确当前图谱口径:保留资金流图谱和关系图谱两个页签。
- 明确资金流图谱验收样例:当前可用身份证号为 `617673198109148314`
## 影响范围
- 不修改业务代码或运行配置。
- 新增的 SQL 只用于已建表环境补字段、补索引和补建手工边表,不会删除或清空已有图谱数据。
- 文档覆盖资金流图谱、关系图谱、专项排查页面、项目分析弹窗和数据库补充口径。
## 验证情况
- 后端编译:`mvn -pl ccdi-project -am compile -DskipTests` 通过。
- 前端构建:`npm run build:prod` 通过;`nvm` 在当前 PowerShell 环境不可用,实际 Node 为 `v22.22.0`
- 接口验收:
- 资金图谱查询返回 10 个节点、18 条边、72 笔。
- 资金边明细分页返回正常。
- 关系图谱查询返回 3 个节点、2 条边。
- 疑似同名企业查询返回 1 条候选。
- 页面验收:
- 已打开真实 `http://localhost/` 页面并进入项目 `90342` 专项排查页。
- 资金图谱查询展示 `18 条资金边``72 笔``302,844.78 元`
- 图谱相关网络请求均返回 `200`
- 浏览器控制台未发现 `error`
## 后续事项
- 若生产已建资金流旧表,执行 `06_lx_fund_graph_existing_table_supplement.sql` 前需先人工核对目标库和表结构差异。
- 当前 PowerShell 环境 `nvm` 不可用,前端构建实际使用 Node `v22.22.0`

View File

@@ -0,0 +1,145 @@
# 图谱预备提交改动与功能清单
## 1. 提交范围建议
本清单按“图谱功能”口径整理预备提交内容。提交前建议只纳入下列图谱相关文件,避免混入其他业务、环境配置或本地产物。
### 1.1 后端图谱代码
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiRelationGraphController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFundGraphMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiRelationGraphMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFundGraphService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiRelationGraphService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFundGraphServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiRelationGraphServiceImpl.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFundGraphMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiRelationGraphMapper.xml`
### 1.2 前端图谱代码
- `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- `ruoyi-ui/src/api/ccdi/graph/relationGraph.js`
- `ruoyi-ui/src/views/ccdiProject/components/detail/graph/FundGraphSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisFundFlowTab.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
### 1.3 数据库脚本
- `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`
- `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`
- `sql/ccdi/graph/04_lx_relation_graph_build_mysql.sql`
- `sql/ccdi/graph/05_lx_relation_graph_seed_test_data.sql`
- `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`
说明:
- `01``03` 是新环境建表参考。
- `06` 是资金流图谱已建表环境补字段、补索引脚本,不删除、不重建、不清空基座数据。
- 生产数据库变更由人工确认后手动执行,不随应用发布自动执行。
### 1.4 文档与验收记录
- `docs/plans/backend/2026-05-28-fund-graph-backend-implementation.md`
- `docs/plans/frontend/2026-05-28-fund-graph-frontend-implementation.md`
- `docs/plans/fullstack/2026-05-28-graph-development-decisions.md`
- `docs/plans/fullstack/2026-05-28-graph-production-db-change-list.md`
- `docs/reports/implementation/2026-05-28-fund-graph-special-check-implementation.md`
- `docs/reports/implementation/2026-05-29-fund-graph-review-fixes-implementation.md`
- `docs/reports/implementation/2026-05-29-fund-graph-statement-query-fix.md`
- `docs/reports/implementation/2026-05-29-graph-acceptance-doc-and-verification.md`
- `docs/reports/implementation/2026-05-29-graph-precommit-summary.md`
- `docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- `docs/tests/records/2026-05-29-graph-acceptance-record.md`
### 1.5 提交前需要谨慎确认的文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
这两个文件只增加 `@Lazy` 解决服务循环依赖,属于图谱运行联动修复,可纳入图谱提交。
以下文件当前工作区存在改动,但是否属于图谱提交需单独确认:
- `ruoyi-admin/src/main/resources/application-dev.yml`
- `tongweb_62318.properties`
- `ruoyi-ui/src/views/ccdiProject/components/detail/FamilyAssetLiabilityDetail.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/FamilyAssetLiabilitySection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
以下内容不建议纳入提交:
- `output/` 下的浏览器截图和验收临时产物。
- `docs/prototypes/` 下的视觉探索图片,除非本次明确要提交设计参考图。
- `ruoyi-admin/src/main/resources/!cLEZGP.docx`
- `.DS_Store`
## 2. 功能清单
### 2.1 资金流图谱
- 主体搜索:按身份证号、姓名或 `object_key` 定位资金流主体。
- 一跳图谱:以当前主体为中心查询一层资金往来。
- 实时聚合:基于 `lx_fund_flow_detail_edge` 按当前筛选条件聚合金额、笔数、首末交易时间。
- 日期筛选:按 `trx_date` 做交易日期范围过滤。
- 金额筛选:支持最小汇总金额和金额范围过滤。
- 方向筛选:支持支出 `1`、收入 `2`
- 家庭关系标签:资金边可展示已写入 `lx_fund_flow_detail_edge.family_relation_type` 的配偶、父母、子女等标签;后端不实时按家庭表匹配。
- 边明细下钻:点击真实资金边分页查看逐笔流水。
- 节点详情:点击节点查看主体字段、证件号、账户数、累计金额和笔数。
- 节点穿透:可穿透节点支持“设为中心查询”和“一层展开”。
- 手工资金流向:支持人工录入主体级资金流向边。
- 手工边展示:手工边参与图谱展示和统一排序,但不提供逐笔流水下钻。
- 排序与上限:真实边和手工边合并后统一按金额、笔数、最近交易时间排序并按 `limit` 截断。
- 缺参提示:手工新增缺少起点或终点时返回明确业务提示。
### 2.2 关系图谱
- 主体搜索:按身份证号、姓名、统一社会信用代码或 `object_key` 查主体。
- 一跳关系图谱:以主体为中心展示家庭、股东、法人关系。
- 家庭关系边:展示员工与家庭成员关系。
- 股东持股边:展示自然人股东、企业股东与企业之间的持股关系。
- 法定代表人边:展示法人和企业之间的代表关系。
- 节点详情:展示主体名称、证件号或统一社会信用代码、主体类型、来源类型。
- 边详情:按关系来源展示关系类型、企业名称、持股比例、出资额、家庭关系字段等。
- 疑似同名企业:按人员姓名召回工商法人和自然人股东候选。
- 同名过多阻断:同名候选过多时提示缩小线索范围。
- 年龄过滤:能解析出生日期时过滤企业成立时未满 18 岁的候选。
### 2.3 页面集成
- 专项排查页:原图谱占位卡替换为真实图谱工作台。
- 专项排查资金图谱:展示搜索区、图谱画布、右侧节点/边详情、边明细分页。
- 专项排查关系图谱:展示关系搜索、图谱画布、右侧节点/边详情、疑似企业面板。
- 项目分析弹窗资金流向:展示简版资金图谱,不展示逐笔流水表。
- 项目分析弹窗关系图谱:展示简版关系图谱,切换页签时触发图谱 resize。
- 图谱画布:使用 ECharts 渲染节点、边、方向、标签和关系区分样式。
### 2.4 数据库与部署
- 资金流图谱新环境表结构脚本。
- 资金流图谱测试数据脚本。
- 资金流图谱已建表补充脚本。
- 关系图谱新环境表结构脚本。
- 关系图谱构建脚本。
- 关系图谱测试数据脚本。
- 统一 `utf8mb4``utf8mb4_general_ci` 口径。
- 图谱 DDL 不自动随应用发布执行。
## 3. 最新内容检查
截至 2026-05-29本预备提交清单已按当前代码和验收结果更新
- 当前范围是“资金流图谱 + 关系图谱”。
- 当前资金流图谱验收样例为 `617673198109148314`
- 当前关系图谱验收样例为 `330101198001010011`
- `docs/tests/` 下本轮图谱验收清单和记录已纳入本次提交清单。
- `output/` 仍保持忽略,浏览器截图不纳入提交。

View File

@@ -0,0 +1,25 @@
# 短时间多次存现本人口径调整实施记录
## 修改内容
- 调整后端大额交易模型规则 `FREQUENT_CASH_DEPOSIT`
- 删除规则 SQL 中关系人流水 `UNION ALL` 分支。
- 规则现仅统计员工本人流水,即 `ccdi_base_staff.id_card = ccdi_bank_statement.cret_no`
## 影响范围
- 影响文件:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 影响规则:短时间多次存现。
- 配偶、父母、子女等员工关系人流水不再自动归集到员工本人名下触发该规则。
- 已生成的历史打标结果不会自动变化,需要重新执行项目分析/打标后生效。
## 验证情况
- 已检查 `selectFrequentCashDepositObjects` 规则片段,确认仅保留员工本人分支。
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests`,编译通过。
- 已通过 `output/frequent_cash_deposit_self_only_verification.sql` 写入临时验证数据并执行规则等价 SQL
- 员工 A 本人 3 笔存现,命中员工 A。
- 配偶 B 同时也是员工B 本人 3 笔存现,命中员工 B。
- 当前本人口径不会把 B 的流水归集到员工 A。
- 对照旧关系人分支会额外命中员工 A验证本次删除关系人分支有效。
- 验证脚本末尾已清理临时员工、关系人与流水数据,并复查三张表残留数均为 0。

View File

@@ -0,0 +1,35 @@
# 涉疑交易明细模型命中范围调整实施记录
## 背景
风险明细模块的「涉疑交易」列表此前在模型规则命中部分限制为 `rule_name like '%可疑%'`,并额外包含 `ABNORMAL_CUSTOMER_TRANSACTION`。该口径会导致大额单笔、短时间多笔存现等已有流水级命中模型无法进入涉疑交易明细。
本次确认的业务口径为:涉疑交易明细应放开展示所有流水级模型命中;名单库命中只在同一笔流水同时命中时优先展示匹配对象和标签,不作为模型命中的过滤条件。
## 修改内容
- 调整 `CcdiProjectOverviewMapper.xml``suspiciousTransactionModelHitSql`
- 保留 `tr.project_id = #{query.projectId}`
- 保留 `tr.bank_statement_id is not null`,仅展示可落到具体流水的命中。
- 删除 `rule_name like '%可疑%'``tr.rule_code = 'ABNORMAL_CUSTOMER_TRANSACTION'` 限制。
- 保持名单库命中逻辑不变:
- 信贷客户账号命中优先级为 1。
- 中介账号、姓名、企业名称命中按原 `matchPriority` 聚合。
- 同一笔流水既有模型命中又有名单命中时,`suspiciousPersonName``nameListHitType` 仍优先取名单命中结果。
- 更新 SQL 单测,明确校验模型命中范围已放开,并校验名单命中优先级聚合逻辑仍存在。
## 影响范围
- 影响页面:项目详情风险明细模块的「涉疑交易」列表及导出。
- 影响数据范围:所有 `ccdi_bank_statement_tag_result.bank_statement_id is not null` 的流水级规则命中均可进入涉疑交易明细。
- 不影响对象级命中:`bank_statement_id is null` 的员工/家庭/资产等对象级结果仍不会进入该流水明细列表。
- 不影响名单库命中阈值和匹配规则。
## 验证
- 已执行 `mvn -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest test`,结果通过:`Tests run: 7, Failures: 0, Errors: 0, Skipped: 0`
- 验证点:
- `suspiciousTransactionModelHitSql` 不再包含 `rule_name like '%可疑%'`
- `suspiciousTransactionModelHitSql` 不再包含 `ABNORMAL_CUSTOMER_TRANSACTION` 单规则特例。
- `suspiciousTransactionModelHitSql` 保留 `tr.bank_statement_id is not null`
- 聚合 SQL 保留基于 `matchPriority` 的名单命中优先展示逻辑。

View File

@@ -0,0 +1,38 @@
# 银行流水打标理财类流水剔除实施记录
## 修改背景
- 员工纪检场景下,单纯理财申购、理财购买、结构性存款、理财赎回等流水属于资产形态转换,不应仅因金额较大命中大额交易或炒股模型。
- 桌面本地知识库中的流水底层打标规则将“理财支出/理财收回”作为独立标签口径,银证类逻辑更聚焦“证券/期货 + 银证转账/银转证/证转银”等特征。
## 修改内容
-`CcdiBankTagAnalysisMapper.xml` 新增 `financialProductExclusionPredicate` SQL 片段,按当前流水字段识别理财类流水并排除。
- 大额交易模型接入理财排除:
- 房车消费支出交易
- 税务支出交易
- 大额单笔收入
- 累计收入超限
- 年流水交易额超限
- 大额存现交易
- 短时间多次存现
- 大额转账交易
- 炒股相关模型接入理财排除:
- 可疑银证大额转账
- 大额炒股
- 从“大额炒股”关键词中移除 `理财`,并补充银证转账、银证、证转银、银转证等更贴近炒股转账的关键词。
- 大额转账交易删除 `USER_MEMO NOT LIKE '%款%'` 排除条件。业务口径为“大额转账单笔超过设置限额”,不应因摘要包含“借款、还款、工程款、货款、服务款”等“款”字统一剔除,避免员工纪检场景漏报。
## 影响范围
- 仅影响银行流水打标 SQL 命中口径,不修改前端、接口结构、参数编码和数据库结构。
- 理财类流水将不再因金额较大命中大额交易模型,也不会因包含“理财”关键词命中炒股模型。
- 单独出现“申购/认购/赎回”不会直接被排除,必须同时伴随理财、产品、存款、本金、余额宝、朝朝宝等理财特征,避免误排证券申购或证转银场景。
- 大额转账交易会重新纳入摘要包含“款”的大额转账流水,仍保留同名账户排除与理财类流水排除。
## 验证情况
- XML 解析校验通过:`CcdiBankTagAnalysisMapper.xml` 可正常解析,当前包含 35 个 `select` 与 9 个 `sql` 片段。
- 后端编译验证通过:`mvn -pl ccdi-project -am compile -DskipTests` 执行成功。
- 编译过程中 Maven 提示 `ccdi-info-collection` 存在既有 `ccdi-lsfx` 重复依赖声明警告,本次未修改该模块。
- 本次不启动前端页面测试,修改范围为后端 Mapper SQL。

View File

@@ -0,0 +1,42 @@
# 微信支付宝提现打标规则实施记录
## 修改背景
- 当前 `WITHDRAW_CNT` 使用 `AMOUNT_CR >= 0` 与微信、支付宝关键词识别频繁提现,容易把消费、转账、充值、退款等第三方支付流水误计为提现。
- `WITHDRAW_AMT` 规则元数据和参数已存在,但 Mapper SQL 仍为占位实现,实际不会命中。
- 本次参考桌面本地知识库中支付宝、微信提现识别口径:优先使用 `BANK in ('ALIPAY','WECHAT')` 与提现特征;当 `BANK` 未识别到微信或支付宝时,再使用平台关键词与提现特征组合兜底。
## 修改内容
-`CcdiBankTagAnalysisMapper.xml` 新增 `thirdPartyWithdrawIncomePredicate`,统一识别微信、支付宝提现到账流水。
- 调整 `WITHDRAW_CNT`
- 金额方向改为 `AMOUNT_CR > 0`,表示提现到账收入。
- 不再仅凭微信、支付宝、财付通等平台关键词计数。
- 按员工和交易日统计提现到账次数,超过 `WITHDRAW_CNT` 阈值命中。
- 补齐 `WITHDRAW_AMT`
- 按员工和交易日汇总提现到账收入金额。
- 超过 `WITHDRAW_AMT` 阈值命中。
- Java 调用链补充 `WITHDRAW_AMT` 参数传递:
- Mapper 方法增加 `amountThreshold` 参数。
- Service 执行规则时传入 `WITHDRAW_AMT` 参数。
- 参数解析器增加 `WITHDRAW_AMT -> WITHDRAW_AMT` 映射。
## 性能考虑
- SQL 保留 `project_id``AMOUNT_CR > 0`、员工身份证关联等基础过滤,先缩小参与匹配的数据集。
- `BANK in ('ALIPAY','WECHAT')` 使用精确判断优先命中。
- 只有 `BANK` 未识别为 `ALIPAY/WECHAT` 时,才走支付宝、微信、财付通等关键词兜底,降低误报和无谓匹配范围。
## 影响范围
- 仅影响异常行为模型下:
- `WITHDRAW_CNT` 微信支付宝频繁提现
- `WITHDRAW_AMT` 微信支付宝提现超额
- 不修改前端、数据库结构和菜单权限。
## 验证情况
- XML 解析校验通过:`CcdiBankTagAnalysisMapper.xml` 可正常解析,当前包含 35 个 `select` 与 10 个 `sql` 片段。
- 后端编译验证通过:`mvn -pl ccdi-project -am compile -DskipTests` 执行成功。
- 文本核对通过:`WITHDRAW_CNT` 已不再使用 `AMOUNT_CR >= 0``WITHDRAW_AMT` 已不再使用占位 SQL。
- 编译过程中 Maven 提示 `ccdi-info-collection` 存在既有 `ccdi-lsfx` 重复依赖声明警告,本次未修改该模块。

View File

@@ -0,0 +1,32 @@
# 大额存现与赌博敏感关键词调整实施记录
## 修改时间
2026-06-04
## 修改背景
员工纪检流水打标模型中,大额存现交易需要按员工本人名下流水统计,不再纳入家庭成员名下流水。疑似赌博敏感交易需要覆盖交易摘要或交易对手中的“游戏、抖币、体彩、福彩”等关注字眼,同时删除单字“球”以减少正常球类消费误报。
## 修改内容
1. 调整 `selectLargeCashDepositStatements`
- 保留员工本人证件号匹配 `ccdi_base_staff.id_card = ccdi_bank_statement.cret_no`
- 移除家庭成员证件号匹配 `ccdi_staff_fmy_relation.relation_cert_no = ccdi_bank_statement.cret_no`
2. 调整 `selectGamblingSensitiveKeywordStatements`
- 删除宽泛单字关键词 `球`
- 保留并补充博彩及敏感娱乐关键词:`游戏``抖币``体彩``福彩``彩票``赌博``赌球``下注``投注``球赛投注``外围``博彩``六合``时时彩``赛车``赌场``筹码``盘口``返水``洗码``庄家``闲家``百家乐``斗牛``炸金花``牌九``麻将``捕鱼``电子游艺``VIP666``USDT下注`
- 命中字段仍为交易摘要 `USER_MEMO` 和交易对手 `CUSTOMER_ACCOUNT_NAME`
## 影响范围
- 大额存现交易命中范围收窄为员工本人名下流水。
- 疑似赌博敏感交易继续覆盖游戏、抖币等关注字眼,同时减少单字“球”造成的正常球类消费误报。
- 不影响短时间多次存现规则;该规则此前已经仅统计员工本人名下流水。
- 不新增表结构和参数配置。
## 验证情况
- 已执行 MyBatis XML 解析检查。
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests` 编译验证。

View File

@@ -0,0 +1,30 @@
# 结果总览弹窗资金流向逐笔流水展示实施记录
## 修改内容
- 去除结果总览“查看详情”弹窗资金边详情中的“弹窗速览”提示。
- 结果总览弹窗“资金流向”页签启用逐笔流水表格展示。
- 保持逐笔流水按现有接口分页加载,不一次性拉取全量数据。
- 针对弹窗场景改为上方大图谱、下方逐笔流水明细布局,避免详情栏挤压图谱画布。
- 资金边金额标签改为“万/亿”短格式,选中边时高亮当前边和两端节点,其余关系降透明。
- 单边明细不再展示“返回全量”按钮,改为点击图谱画布空白区域恢复到刚进入“资金流向”页签时的全量图谱第一眼状态。
- 空白点击恢复全量时同步清除选中边、选中节点、逐笔流水数据、明细加载状态和 ECharts 内部强调状态,避免画布残留单条资金边高亮。
- 空白点击监听仅绑定资金流向图谱画布,不影响下方逐笔流水表格和分页操作。
- 逐笔流水接口增加请求序号校验,返回全量后丢弃旧选中边的异步响应,避免旧请求回写导致明细区再次出现。
- 弹窗资金流向明细隐藏累计金额、交易笔数、关系三张汇总卡片,减少重复信息占用空间。
## 影响范围
- 前端页面:结果总览“查看详情”弹窗中的“资金流向”页签。
- 复用组件:`FundGraphSection` 新增弹窗场景可配置项,默认行为不变。
- 不影响专项排查页图谱展示,不修改后端接口和数据库。
## 验证情况
- 前端命令执行前已尝试 `nvm use`,当前环境未识别 `nvm`;实际 Node 版本为 `v22.22.0`npm 版本为 `10.9.4`
- 已多次执行 `npm run build:prod`,构建通过,仅保留现有资源体积提示。
- 已使用真实页面验证 `http://localhost/ccdiProject/detail/90342?tab=overview`:在结果总览点击“查看详情”,切换“资金流向”,选中交易笔数最多的资金边。
- 多笔金额场景验证结果:当前图谱 19 条资金边,选中边为“彭静勇 → 张建强”,交易笔数 5逐笔流水表格显示 5 条并分页显示“共 5 条”。
- 展示验证结果:旧“弹窗速览”提示和“项目分析弹窗仅展示汇总信息”说明均不再出现;金额标签压缩为 `2.22万, 5笔`;汇总卡片已隐藏;逐笔流水展示在图谱下方,未裁切图谱区域。
- 恢复全量验证结果:选中“彭静勇 → 张建强”后展示 5 条逐笔流水,其余 18 条资金边降透明;点击图谱空白区域后,旧明细请求未回写,选中边、选中节点、逐笔流水数据和明细总数均清空;图表仍展示 15 个图形节点、19 条资金边,边样式恢复为 opacity `0.9`、width `1.9`,不再残留单条高亮边。
- 页面截图已保存到 `output/browser-use/project-analysis-fund-flow-final-graph.png``output/browser-use/project-analysis-fund-flow-final-detail.png``output/browser-use/project-analysis-fund-flow-reset-all.png``output/browser-use/project-analysis-fund-flow-reset-first-view.png``output/browser-use/project-analysis-fund-flow-blank-click-reset.png`

View File

@@ -0,0 +1,127 @@
# 图谱功能改动梳理与验收清单
## 1. 验收目标
本清单用于继续验收当前图谱相关改动,覆盖资金流图谱和关系图谱两部分,重点确认:
- 已改动内容分别承担什么功能。
- 后端接口是否返回正常,是否出现 `500`、鉴权异常或 SQL 报错。
- 专项排查和项目分析弹窗中的真实页面是否能打开和查询。
- 已建图谱表不重复建表,仅核对必要字段、索引和补充脚本。
## 2. 改动梳理
### 2.1 资金流图谱
| 类型 | 文件/入口 | 功能 |
| --- | --- | --- |
| 后端 Controller | `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java` | 提供主体搜索、一层资金图谱查询、边流水明细分页、手工资金流向保存接口。 |
| 后端 Service | `CcdiFundGraphServiceImpl.java` | 参数归一化、中心主体定位、真实边和手工边合并排序、节点组装、手工边保存、默认 `limit` 防空。 |
| 后端 Mapper | `CcdiFundGraphMapper.java``CcdiFundGraphMapper.xml` | 读取 `lx_fund_flow_*` 表,按账户明细实时聚合主体资金边,查询逐笔流水,写入手工边。 |
| DTO/VO | `CcdiFundGraph*DTO.java``CcdiFundGraph*VO.java` | 定义查询条件、手工边保存参数、图谱节点、资金边、流水明细返回结构。 |
| 前端 API | `ruoyi-ui/src/api/ccdi/graph/fundGraph.js` | 封装 `/ccdi/project/fund-graph/*` 接口。 |
| 前端组件 | `FundGraphSection.vue` | 资金图谱主工作台搜索、日期范围、最小金额、ECharts 图谱、节点/边详情、边流水分页、手工新增资金流向。 |
| 专项排查入口 | `SpecialCheck.vue` | 将原图谱占位卡替换为真实图谱组件。 |
| 项目分析弹窗 | `ProjectAnalysisDialog.vue``ProjectAnalysisFundFlowTab.vue` | 在“资金流向”页签内展示简版资金图谱,弹窗内不展示逐笔流水表。 |
| SQL | `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql``02_lx_fund_graph_seed_test_data.sql``06_lx_fund_graph_existing_table_supplement.sql` | 记录资金图谱必要表结构、开发联调测试数据,以及已建表环境补字段/补索引脚本。 |
### 2.2 关系图谱
| 类型 | 文件/入口 | 功能 |
| --- | --- | --- |
| 后端 Controller | `CcdiRelationGraphController.java` | 提供主体搜索、一层关系图谱查询、疑似同名企业查询接口。 |
| 后端 Service | `CcdiRelationGraphServiceImpl.java` | 关系图谱主体定位、家庭/股东/法人边合并展示、疑似企业按姓名召回与年龄规则过滤。 |
| 后端 Mapper | `CcdiRelationGraphMapper.java``CcdiRelationGraphMapper.xml` | 读取 `lx_rel_node``lx_rel_family_edge``lx_rel_stock_edge``lx_rel_represent_edge`。 |
| DTO/VO | `CcdiRelationGraph*DTO.java``CcdiRelationGraph*VO.java` | 定义关系图谱查询、节点、边、疑似企业候选返回结构。 |
| 前端 API | `ruoyi-ui/src/api/ccdi/graph/relationGraph.js` | 封装 `/ccdi/project/relation-graph/*` 接口。 |
| 前端组件 | `FundGraphSection.vue` | 同一组件内提供“关系图谱”页签、节点/边详情和疑似同名企业面板。 |
| 项目分析弹窗 | `ProjectAnalysisDialog.vue` | “关系图谱”页签由占位页改为真实关系图谱简版展示。 |
| SQL | `03_lx_relation_graph_mysql_ddl.sql``04_lx_relation_graph_build_mysql.sql``05_lx_relation_graph_seed_test_data.sql` | 关系图谱建表、构建和测试数据脚本。DDL 已包含旧表补字段/补索引逻辑。 |
### 2.3 关联修复
| 文件 | 功能 |
| --- | --- |
| `CcdiModelParamServiceImpl.java` | 对 `ICcdiBankTagService` 增加 `@Lazy`,缓解服务循环依赖启动问题。 |
| `CcdiProjectOverviewServiceImpl.java` | 对 `ICcdiModelParamService` 增加 `@Lazy`,缓解服务循环依赖启动问题。 |
| `application-dev.yml``tongweb_62318.properties` | 本地/部署运行配置存在改动,不纳入本次图谱验收清单。 |
## 3. 建表与补充脚本验收口径
当前表已建好,本轮不重复执行建表脚本。验收时按以下口径处理:
- `CREATE TABLE IF NOT EXISTS` 可保留在仓库内,作为新环境初始化和评审依据。
- 已存在表只做字段、索引、字符集、排序规则核对。
- 如已建资金流图谱表缺字段或缺索引,优先使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补充,不要先删表再重建。
- 如已建关系图谱表缺字段或缺索引,优先使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑。
- 所有图谱表、字符字段和新增索引保持 `utf8mb4``utf8mb4_general_ci` 口径。
- 执行包含中文内容的 SQL 文件时使用 `bin/mysql_utf8_exec.sh <sql-file>`
- 生产环境 DDL 和补充 SQL 由人工确认后手动执行,不随应用发布自动执行。
资金流图谱需核对:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
关系图谱需核对:
- `lx_rel_node`
- `lx_rel_family_edge`
- `lx_rel_stock_edge`
- `lx_rel_represent_edge`
## 4. 验收清单
### 4.1 静态与构建
- [ ] 后端 `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- [ ] 前端执行前确认 Node 版本;如 `nvm` 不可用,记录实际 Node 版本。
- [ ] 前端 `npm run build:prod` 通过,无新增编译错误。
- [ ] 检查暂存区确认验收文档、业务代码、SQL、构建产物边界清晰。
### 4.2 资金流图谱接口
- [ ] 未登录访问图谱接口返回认证失败,不泄露数据。
- [ ] 管理员登录后,`/ccdi/project/fund-graph/search` 可按身份证号查到主体。
- [ ] `/ccdi/project/fund-graph/graph` 默认查询返回节点、边、总金额、交易笔数。
- [ ] 日期范围查询返回正常,不出现 SQL 字符集/排序规则错误。
- [ ] `direction=1` 支出查询返回正常。
- [ ] `direction=2` 收入查询返回正常。
- [ ] 点击边对应的 `/edge-detail` 分页返回正常,不再出现 `LIMIT null`
- [ ] 手工新增资金流向缺少起点时返回明确业务错误,不抛服务端异常栈。
- [ ] 手工边与真实边合并后统一排序和统一 `limit`
### 4.3 关系图谱接口
- [ ] `/ccdi/project/relation-graph/search` 可按身份证号或姓名查到主体。
- [ ] `/ccdi/project/relation-graph/graph` 返回家庭、股东、法人关系边。
- [ ] `/ccdi/project/relation-graph/suspected-enterprises` 可按姓名召回疑似企业。
- [ ] 同名候选过多时返回阻断提示,不直接大批量展示。
- [ ] 出生日期或身份证可用时,成立时未满 18 岁候选被过滤。
### 4.4 专项排查页面
- [ ] 登录后进入真实项目详情页,不打开原型页。
- [ ] 切换到“专项排查”,图谱分析区域出现。
- [ ] 资金流图谱默认空态文案正确。
- [ ] 输入身份证号后点击查询,资金边统计展示正确。
- [ ] 图谱画布非空白,页面无明显遮挡、错位。
- [ ] 点击资金边后右侧展示金额、笔数、关系标签和逐笔流水。
- [ ] 切换到关系图谱页签,搜索后页面无白屏、无控制台错误。
- [ ] 浏览器控制台无 `error`,图谱相关网络请求均为 `200`
### 4.5 项目分析弹窗
- [ ] 在结果总览人员行点击“查看详情”打开项目分析弹窗。
- [ ] “资金流向”页签展示简版图谱,无搜索栏,画布能渲染。
- [ ] “关系图谱”页签展示简版关系图谱,切换页签后图谱重新 resize。
- [ ] 弹窗版不展示逐笔流水表,仅展示汇总信息。
- [ ] 弹窗控制台无图谱 resize、ECharts 初始化、接口调用异常。
### 4.6 待重点复核项
- [ ] `nvm` 在当前 PowerShell 环境不可用;前端构建已记录实际 Node 版本。
- [ ] 已建表环境执行补充前,先人工确认目标库、表结构差异和备份策略。

View File

@@ -0,0 +1,101 @@
# 2026-05-29 图谱功能验收执行记录
## 1. 执行信息
- 执行时间2026-05-29 17:36 ~ 17:43Asia/Shanghai
- 验收环境:本机真实服务
- 前端地址:`http://localhost/`
- 后端地址:`http://localhost:62318`
- 验收清单:`docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- 测试项目:`projectId=90342`,项目名 `test拉取行内流水`
- 管理员账号:`admin`
## 2. 构建与静态验证
| 项目 | 命令 | 结果 |
| --- | --- | --- |
| 后端编译 | `mvn -pl ccdi-project -am compile -DskipTests` | 通过 |
| 前端 Node 确认 | `nvm use; node -v` | `nvm` 不可用,实际 Node 为 `v22.22.0` |
| 前端构建 | `npm run build:prod` | 通过,有既有资源体积 warning |
说明:
- Maven 输出存在既有重复依赖声明 warning`ccdi-info-collection``ccdi-lsfx` 依赖重复。
- 前端构建只有资源体积 warning无编译失败。
## 3. 接口验收
### 3.1 鉴权
- 未登录访问 `/ccdi/project/fund-graph/search`:返回 `401`,符合预期。
- 未登录访问 `/ccdi/project/relation-graph/search`:返回 `401`,符合预期。
- 使用 `/login` 登录成功,后续接口带 `Bearer token` 验收。
### 3.2 资金流图谱
测试身份证号:`617673198109148314`
| 接口/场景 | 结果 |
| --- | --- |
| `/ccdi/project/fund-graph/search?keyword=617673198109148314` | `code=200`,查到 1 个主体 |
| `/ccdi/project/fund-graph/graph?keyword=617673198109148314&limit=20&minTotalAmount=0` | `code=200`10 个节点、18 条边、72 笔、总金额 `302844.78` |
| 日期范围查询 | `code=200`18 条边、72 笔 |
| `direction=1` 支出查询 | `code=200`9 条边、45 笔 |
| `direction=2` 收入查询 | `code=200`9 条边、27 笔 |
| 第一条资金边明细分页 | `code=200`,返回 5 条,总数 5 |
| 手工新增资金流向缺少起点 | 返回 `code=500`,业务提示:`起点主体不能为空` |
结论:
- 资金图谱核心查询、方向筛选、边明细分页可用。
- 手工新增缺少必填项时已返回明确提示,未出现服务端异常栈。
### 3.3 关系图谱
测试身份证号:`330101198001010011`
| 接口/场景 | 结果 |
| --- | --- |
| `/ccdi/project/relation-graph/search?keyword=330101198001010011` | `code=200`,查到 1 个主体 |
| `/ccdi/project/relation-graph/graph?keyword=330101198001010011&limit=80` | `code=200`3 个节点、2 条边 |
| `/ccdi/project/relation-graph/suspected-enterprises` | `code=200`,返回 1 条疑似企业,`blocked=false` |
结论:
- 关系图谱接口可用,疑似企业查询可用。
## 4. 页面验收
使用 Playwright CLI 打开真实页面 `http://localhost/` 执行:
1. 登录系统。
2. 进入初核项目管理。
3. 打开项目 `90342` 的详情页。
4. 切换到“专项排查”。
5. 在“资金流图谱”输入 `617673198109148314` 并查询。
6. 切换“关系图谱”,输入 `330101198001010011` 并查询。
7. 检查控制台和网络请求。
结果:
- 专项排查页面可正常加载图谱分析区域。
- 资金流图谱查询后页面展示 `18 条资金边``72 笔``302,844.78 元`
- 关系图谱查询接口返回 `200`,页面无白屏。
- 图谱相关网络请求:
- `/dev-api/ccdi/project/fund-graph/graph?keyword=617673198109148314&minTotalAmount=1000&limit=20` 返回 `200`
- `/dev-api/ccdi/project/relation-graph/graph?keyword=330101198001010011&limit=80` 返回 `200`
- 浏览器控制台 `error` 数量为 0。
- 截图证据:`output/playwright/graph-acceptance-special-check.png`
## 5. 注意事项
| 编号 | 级别 | 事项 | 影响 | 建议 |
| --- | --- | --- | --- | --- |
| GRAPH-002 | P2 | 当前 PowerShell 环境 `nvm` 不可用 | 不满足“前端命令前先 nvm use”的执行规范 | 修复 nvm 安装或 PATH本轮实际使用 Node `v22.22.0` |
| GRAPH-004 | P2 | 资金流 DDL 原先只有新建表口径,已建旧表需要差异补充 | 生产若已建旧表但缺字段,单靠 `CREATE TABLE IF NOT EXISTS` 不会补齐 | 已补充 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`,执行前需人工核对 |
## 6. 阶段性结论
本轮未发现图谱接口 `500`、页面白屏或浏览器控制台 error。资金流图谱和关系图谱在当前本机真实服务上均可查询。
当前图谱主链路验收通过。剩余事项为环境类或发布前核对类事项:`GRAPH-002` 需要修复本机 `nvm` 环境,`GRAPH-004` 需要在生产执行补充 SQL 前人工核对目标库和表结构差异。

View File

@@ -0,0 +1,33 @@
import request from "@/utils/request";
export function searchFundGraphSubjects(query) {
return request({
url: "/ccdi/project/fund-graph/search",
method: "get",
params: query,
});
}
export function getFundGraph(query) {
return request({
url: "/ccdi/project/fund-graph/graph",
method: "get",
params: query,
});
}
export function getFundGraphEdgeDetail(query) {
return request({
url: "/ccdi/project/fund-graph/edge-detail",
method: "get",
params: query,
});
}
export function saveFundGraphManualEdge(data) {
return request({
url: "/ccdi/project/fund-graph/manual-edge",
method: "post",
data,
});
}

View File

@@ -0,0 +1,25 @@
import request from "@/utils/request";
export function searchRelationGraphSubjects(query) {
return request({
url: "/ccdi/project/relation-graph/search",
method: "get",
params: query,
});
}
export function getRelationGraph(query) {
return request({
url: "/ccdi/project/relation-graph/graph",
method: "get",
params: query,
});
}
export function getRelationGraphSuspectedEnterprises(query) {
return request({
url: "/ccdi/project/relation-graph/suspected-enterprises",
method: "get",
params: query,
});
}

View File

@@ -27,7 +27,7 @@
:disabled="!projectId" :disabled="!projectId"
@click="handleOverviewReportExport" @click="handleOverviewReportExport"
> >
一键导出 导出报告
</el-button> </el-button>
</div> </div>
<overview-stats :summary="currentData.summary" /> <overview-stats :summary="currentData.summary" />

View File

@@ -244,28 +244,31 @@ export default {
.project-analysis-abnormal-tab { .project-analysis-abnormal-tab {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 20px;
} }
.abnormal-card { .abnormal-card {
padding: 20px; border: 1px solid #d9e1ed;
border: 1px solid #e2e8f0;
background: #fff; background: #fff;
overflow: hidden;
} }
.abnormal-card__header { .abnormal-card__header {
margin-bottom: 14px; margin-bottom: 0;
padding: 22px 30px;
border-bottom: 1px solid #e3eaf3;
} }
.abnormal-card__title { .abnormal-card__title {
font-size: 16px; font-size: 18px;
font-weight: 600; font-weight: 700;
color: #0f172a; color: #0f172a;
} }
.abnormal-card__content { .abnormal-card__content {
display: grid; display: grid;
gap: 16px; gap: 0;
background: #ffffff;
} }
.abnormal-card__subtitle { .abnormal-card__subtitle {
@@ -274,14 +277,15 @@ export default {
} }
.abnormal-table { .abnormal-table {
border-radius: 12px; border-radius: 0;
overflow: hidden; overflow: hidden;
} }
.abnormal-pagination { .abnormal-pagination {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 16px; padding: 18px 24px 20px;
border-top: 1px solid #e8eef6;
} }
.multi-line-cell { .multi-line-cell {
@@ -303,7 +307,7 @@ export default {
.tag-list { .tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 10px;
} }
.object-card-grid { .object-card-grid {
@@ -314,8 +318,8 @@ export default {
.object-card { .object-card {
padding: 16px; padding: 16px;
border: 1px solid #e2e8f0; border: 1px solid #d9e1ed;
background: #f8fafc; background: #ffffff;
} }
.object-card__header { .object-card__header {
@@ -364,7 +368,8 @@ export default {
margin-top: 12px; margin-top: 12px;
padding: 12px; padding: 12px;
border: 1px solid #dbeafe; border: 1px solid #dbeafe;
background: #eff6ff; border-left: 3px solid #245b8f;
background: #f4f8fd;
} }
.object-card__snapshot-label { .object-card__snapshot-label {
@@ -392,4 +397,36 @@ export default {
color: #1e293b; color: #1e293b;
text-align: right; text-align: right;
} }
.abnormal-table ::v-deep(.el-table__header-wrapper th) {
height: 58px;
padding: 0 22px;
border-bottom: 1px solid #d9e1ed;
background: #f3f6fa;
color: #5f7592;
font-size: 16px;
font-weight: 700;
}
.abnormal-table ::v-deep(.el-table__body-wrapper td) {
padding: 22px;
border-bottom: 1px solid #e6edf5;
}
.abnormal-table ::v-deep(.cell) {
padding-left: 0;
padding-right: 0;
}
.tag-list ::v-deep(.el-tag) {
height: 28px;
padding: 0 10px;
border: 1px solid #c8d6e8;
border-radius: 2px;
background: #ffffff;
color: #245b8f;
font-size: 12px;
font-weight: 600;
line-height: 26px;
}
</style> </style>

View File

@@ -2,7 +2,7 @@
<el-dialog <el-dialog
title="项目分析" title="项目分析"
:visible.sync="visibleProxy" :visible.sync="visibleProxy"
width="92%" width="88%"
top="2vh" top="2vh"
append-to-body append-to-body
custom-class="project-analysis-dialog" custom-class="project-analysis-dialog"
@@ -44,7 +44,7 @@
<el-button type="text" size="mini" @click="handleRetryDetail">重试</el-button> <el-button type="text" size="mini" @click="handleRetryDetail">重试</el-button>
</template> </template>
</el-alert> </el-alert>
<el-tabs v-model="activeTab" class="project-analysis-tabs" stretch> <el-tabs v-model="activeTab" class="project-analysis-tabs" stretch @tab-click="handleTabChange">
<el-tab-pane label="异常明细" name="abnormalDetail"> <el-tab-pane label="异常明细" name="abnormalDetail">
<project-analysis-abnormal-tab <project-analysis-abnormal-tab
:detail-data="dialogData.abnormalDetail" :detail-data="dialogData.abnormalDetail"
@@ -60,10 +60,24 @@
<project-analysis-placeholder-tab :tab-data="getTabData('creditSummary')" /> <project-analysis-placeholder-tab :tab-data="getTabData('creditSummary')" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="关系图谱" name="relationshipGraph"> <el-tab-pane label="关系图谱" name="relationshipGraph">
<project-analysis-placeholder-tab :tab-data="getTabData('relationshipGraph')" /> <project-analysis-fund-flow-tab
v-if="activeTab === 'relationshipGraph'"
ref="relationshipGraphTab"
:project-id="projectId"
:person="person"
:model-summary="modelSummary"
initial-graph-tab="relation"
:graph-tabs="['relation']"
/>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="资金流向" name="fundFlow"> <el-tab-pane label="资金流向" name="fundFlow">
<project-analysis-placeholder-tab :tab-data="getTabData('fundFlow')" /> <project-analysis-fund-flow-tab
v-if="activeTab === 'fundFlow'"
ref="fundFlowTab"
:project-id="projectId"
:person="person"
:model-summary="modelSummary"
/>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
@@ -75,6 +89,7 @@
<script> <script>
import { getOverviewPersonAnalysisDetail } from "@/api/ccdi/projectOverview"; import { getOverviewPersonAnalysisDetail } from "@/api/ccdi/projectOverview";
import ProjectAnalysisAbnormalTab from "./ProjectAnalysisAbnormalTab"; import ProjectAnalysisAbnormalTab from "./ProjectAnalysisAbnormalTab";
import ProjectAnalysisFundFlowTab from "./ProjectAnalysisFundFlowTab";
import ProjectAnalysisPlaceholderTab from "./ProjectAnalysisPlaceholderTab"; import ProjectAnalysisPlaceholderTab from "./ProjectAnalysisPlaceholderTab";
import ProjectAnalysisSidebar from "./ProjectAnalysisSidebar"; import ProjectAnalysisSidebar from "./ProjectAnalysisSidebar";
import { buildProjectAnalysisDialogData } from "./preliminaryCheck.mock"; import { buildProjectAnalysisDialogData } from "./preliminaryCheck.mock";
@@ -83,6 +98,7 @@ export default {
name: "ProjectAnalysisDialog", name: "ProjectAnalysisDialog",
components: { components: {
ProjectAnalysisAbnormalTab, ProjectAnalysisAbnormalTab,
ProjectAnalysisFundFlowTab,
ProjectAnalysisPlaceholderTab, ProjectAnalysisPlaceholderTab,
ProjectAnalysisSidebar, ProjectAnalysisSidebar,
}, },
@@ -208,6 +224,16 @@ export default {
this.resetDialogState(); this.resetDialogState();
this.$emit("close"); this.$emit("close");
}, },
handleTabChange() {
this.$nextTick(() => {
const tabRef = this.activeTab === "relationshipGraph"
? this.$refs.relationshipGraphTab
: this.$refs.fundFlowTab;
if (tabRef && tabRef.ensureGraphReady) {
tabRef.ensureGraphReady();
}
});
},
}, },
}; };
</script> </script>
@@ -216,14 +242,14 @@ export default {
.project-analysis-dialog__body { .project-analysis-dialog__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: calc(96vh - 64px); min-height: calc(92vh - 64px);
border: 1px solid #dde3ec; border: 1px solid #dde3ec;
background: #ffffff; background: #ffffff;
overflow: hidden; overflow: hidden;
} }
.project-analysis-header { .project-analysis-header {
padding: 32px 36px 24px; padding: 28px 40px 24px;
border-bottom: 1px solid #dde3ec; border-bottom: 1px solid #dde3ec;
background: #ffffff; background: #ffffff;
} }
@@ -241,15 +267,15 @@ export default {
.project-analysis-header__eyebrow { .project-analysis-header__eyebrow {
color: #65758d; color: #65758d;
font-size: 16px; font-size: 15px;
font-weight: 600; font-weight: 700;
line-height: 1; line-height: 1;
} }
.project-analysis-header__title { .project-analysis-header__title {
margin-top: 18px; margin-top: 18px;
color: #101a2b; color: #101a2b;
font-size: 30px; font-size: 28px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
@@ -281,9 +307,9 @@ export default {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 36px; gap: 36px;
min-height: 700px; min-height: 640px;
max-height: calc(96vh - 168px); max-height: calc(92vh - 150px);
padding: 36px; padding: 20px 40px 28px;
overflow: auto; overflow: auto;
background: #ffffff; background: #ffffff;
} }
@@ -295,7 +321,8 @@ export default {
} }
.project-analysis-layout__sidebar { .project-analysis-layout__sidebar {
flex: 0 0 420px; flex: 0 0 34%;
max-width: 460px;
} }
.project-analysis-layout__main { .project-analysis-layout__main {
@@ -310,7 +337,7 @@ export default {
} }
.project-analysis-tabs { .project-analysis-tabs {
margin-top: -6px; margin-top: -2px;
} }
</style> </style>
@@ -342,21 +369,21 @@ export default {
} }
.el-tabs__item { .el-tabs__item {
height: 46px; height: 52px;
padding: 0 24px !important; padding: 0 24px !important;
color: #2a374a; color: #2a374a;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
line-height: 46px; line-height: 52px;
} }
.el-tabs__item.is-active { .el-tabs__item.is-active {
color: #245b8f; color: #245b8f;
font-weight: 600; font-weight: 700;
} }
.el-tabs__active-bar { .el-tabs__active-bar {
height: 3px; height: 4px;
background: #245b8f; background: #245b8f;
} }

View File

@@ -0,0 +1,259 @@
<template>
<div class="project-analysis-fund-graph" :class="rootClasses">
<fund-graph-section
ref="fundGraphSection"
:initial-keyword="defaultKeyword"
:initial-graph-tab="initialGraphTab"
:auto-scroll="false"
:hide-header="true"
:graph-tabs="graphTabs"
:layout-scale="1.03"
:show-edge-detail-table="true"
:edge-detail-table-height="220"
compact-graph-labels
:show-edge-metrics="!isFundOnly"
:stacked-fund-detail-pane="isFundOnly"
hide-empty-detail-pane
/>
</div>
</template>
<script>
import FundGraphSection from "./graph/FundGraphSection";
export default {
name: "ProjectAnalysisFundFlowTab",
components: {
FundGraphSection,
},
props: {
projectId: {
type: [String, Number],
default: null,
},
person: {
type: Object,
default: () => null,
},
modelSummary: {
type: Object,
default: () => ({}),
},
initialGraphTab: {
type: String,
default: "fund",
validator: (value) => ["fund", "relation"].includes(value),
},
graphTabs: {
type: Array,
default: () => ["fund", "relation"],
},
},
computed: {
isFundOnly() {
return this.initialGraphTab === "fund" && this.graphTabs.length === 2;
},
rootClasses() {
return {
"project-analysis-fund-graph--fund": this.isFundOnly,
"project-analysis-fund-graph--relation": !this.isFundOnly,
};
},
defaultKeyword() {
return (this.modelSummary && this.modelSummary.staffIdCard)
|| (this.person && (this.person.idNo || this.person.staffIdCard || this.person.staffName || this.person.name))
|| "";
},
},
methods: {
ensureGraphReady() {
this.$nextTick(() => {
const graphSection = this.$refs.fundGraphSection;
if (graphSection && graphSection.resizeChart) {
graphSection.resizeChart();
}
if (graphSection && graphSection.resizeRelationChart) {
graphSection.resizeRelationChart();
}
if (graphSection && graphSection.renderChart) {
graphSection.renderChart();
}
if (graphSection && graphSection.renderRelationChart) {
graphSection.renderRelationChart();
}
});
},
},
};
</script>
<style lang="scss" scoped>
.project-analysis-fund-graph {
min-height: 780px;
::v-deep .graph-analysis-section {
margin-top: 0;
box-shadow: none;
}
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
min-height: 760px;
}
::v-deep .fund-workbench.fund-workbench--single {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
min-height: 560px;
}
::v-deep .fund-workbench--stacked {
display: flex;
flex-direction: column;
}
::v-deep .fund-left-pane {
padding: 14px 16px 16px;
overflow: hidden;
border-right: 0;
}
::v-deep .fund-right-pane {
position: relative;
z-index: 2;
padding: 14px;
background: #ffffff;
border-top: 1px solid #dce7f2;
overflow: hidden;
}
::v-deep .graph-canvas-wrap {
height: auto;
min-height: 520px;
}
::v-deep .detail-empty {
min-height: 240px;
}
::v-deep .query-row {
display: none;
}
::v-deep .keyword-input {
width: 196px;
}
::v-deep .date-range {
width: 216px;
}
::v-deep .detail-table {
font-size: 12px;
}
::v-deep .detail-table .el-table__cell {
padding: 5px 0;
}
::v-deep .pagination-container {
margin-top: 8px;
padding: 0;
}
::v-deep .pagination-container .el-pagination {
display: flex;
justify-content: flex-end;
white-space: nowrap;
}
::v-deep .query-row {
gap: 8px;
margin-bottom: 10px;
}
::v-deep .graph-shell {
border-radius: 10px;
overflow: hidden;
}
::v-deep .graph-canvas {
min-height: 520px;
}
::v-deep .detail-title {
font-size: 15px;
line-height: 1.35;
}
::v-deep .node-field-row,
::v-deep .edge-metrics span,
::v-deep .edge-metrics strong,
::v-deep .detail-subtitle {
font-size: 12px;
}
}
.project-analysis-fund-graph--relation {
min-height: 520px;
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 760px;
min-height: 450px;
}
::v-deep .graph-canvas-wrap,
::v-deep .detail-empty {
min-height: 360px;
}
::v-deep .graph-canvas {
min-height: 360px;
}
}
@media (max-width: 1440px) {
.project-analysis-fund-graph--fund {
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
::v-deep .fund-workbench.fund-workbench--single {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
::v-deep .graph-canvas-wrap {
min-height: 500px;
}
::v-deep .detail-empty {
min-height: 220px;
}
::v-deep .graph-canvas {
min-height: 500px;
}
}
.project-analysis-fund-graph--relation {
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 720px;
}
::v-deep .graph-canvas-wrap,
::v-deep .detail-empty {
min-height: 340px;
}
::v-deep .graph-canvas {
min-height: 340px;
}
}
}
</style>

View File

@@ -91,21 +91,21 @@ export default {
} }
.sidebar-profile { .sidebar-profile {
padding: 24px 26px 20px; padding: 34px 42px 28px;
border-bottom: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5;
} }
.sidebar-summary { .sidebar-summary {
padding: 22px 26px 26px; padding: 30px 42px 34px;
border-top: 1px solid #dde3ec; border-top: 1px solid #dde3ec;
background: #fcfdfe; background: #fcfdfe;
} }
.sidebar-profile__identity-label { .sidebar-profile__identity-label {
color: #637187; color: #637187;
font-size: 15px; font-size: 16px;
font-weight: 600; font-weight: 700;
margin-bottom: 16px; margin-bottom: 28px;
} }
.sidebar-profile__name-row { .sidebar-profile__name-row {
@@ -117,7 +117,7 @@ export default {
.sidebar-profile__name { .sidebar-profile__name {
color: #111827; color: #111827;
font-size: 30px; font-size: 34px;
font-weight: 700; font-weight: 700;
line-height: 1.2; line-height: 1.2;
} }
@@ -126,30 +126,30 @@ export default {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 64px; min-width: 102px;
height: 28px; height: 44px;
padding: 0 10px; padding: 0 16px;
border: 1px solid #edcaca; border: 1px solid #f0c6c1;
border-radius: 2px; border-radius: 2px;
background: #fbefef; background: #fff3f2;
color: #ad2f2f; color: #c43d33;
font-size: 14px; font-size: 17px;
font-weight: 600; font-weight: 600;
line-height: 26px; line-height: 42px;
text-align: center; text-align: center;
} }
.sidebar-profile__meta { .sidebar-profile__meta {
display: grid; display: grid;
gap: 0; gap: 0;
margin-top: 18px; margin-top: 30px;
} }
.sidebar-profile__item { .sidebar-profile__item {
display: grid; display: grid;
grid-template-columns: 88px minmax(0, 1fr); grid-template-columns: 116px minmax(0, 1fr);
gap: 12px; gap: 16px;
padding: 10px 0; padding: 18px 0;
border-bottom: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5;
} }
@@ -160,46 +160,46 @@ export default {
.sidebar-profile__label, .sidebar-profile__label,
.sidebar-summary__count-label { .sidebar-summary__count-label {
color: #637187; color: #637187;
font-size: 15px; font-size: 16px;
} }
.sidebar-profile__value, .sidebar-profile__value,
.sidebar-summary__count-value { .sidebar-summary__count-value {
color: #172033; color: #172033;
font-size: 16px; font-size: 17px;
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.4;
word-break: break-all; word-break: break-all;
} }
.sidebar-summary__title { .sidebar-summary__title {
margin: 0 0 18px; margin: 0 0 24px;
color: #223047; color: #223047;
font-size: 16px; font-size: 18px;
font-weight: 700; font-weight: 700;
} }
.sidebar-summary__count { .sidebar-summary__count {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 12px; gap: 16px;
margin-top: 0; margin-top: 0;
margin-bottom: 22px; margin-bottom: 28px;
} }
.sidebar-summary__tags { .sidebar-summary__tags {
display: grid; display: grid;
gap: 10px; gap: 16px;
margin-top: 0; margin-top: 0;
} }
.sidebar-summary__count-label { .sidebar-summary__count-label {
font-size: 15px; font-size: 17px;
} }
.sidebar-summary__count-value { .sidebar-summary__count-value {
color: #172033; color: #172033;
font-size: 22px; font-size: 40px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
@@ -212,14 +212,14 @@ export default {
} }
.sidebar-tag-list ::v-deep(.el-tag) { .sidebar-tag-list ::v-deep(.el-tag) {
height: 28px; height: 38px;
padding: 0 10px; padding: 0 16px;
border: 1px solid #bfd0e2; border: 1px solid #cad8eb;
border-radius: 2px; border-radius: 2px;
background: #ffffff; background: #ffffff;
color: #245b8f; color: #245b8f;
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 26px; line-height: 36px;
} }
</style> </style>

View File

@@ -353,7 +353,61 @@ const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "MODEL_RULE", label: "模型规则命中" }, { value: "MODEL_RULE", label: "模型规则命中" },
]; ];
const normalizeHitTags = (hitTags) => (Array.isArray(hitTags) ? hitTags : []); const normalizeSuspiciousType = (value) => (
SUSPICIOUS_TYPE_OPTIONS.some((item) => item.value === value) ? value : "ALL"
);
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE = "ABNORMAL_CUSTOMER_TRANSACTION";
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME = "与客户之间非正常资金往来";
const resolveAbnormalCustomerTransactionRuleName = (nameListHitType) => {
if (nameListHitType === "中介") {
return "疑似与中介往来";
}
if (nameListHitType === "信贷客户") {
return "与信贷客户之间非正常资金往来";
}
return ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME;
};
const normalizeRuleName = (tag, context) => {
if (!tag) {
return "";
}
if (
tag.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE
|| tag.ruleName === "异常交易"
|| tag.ruleName === ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME
) {
return resolveAbnormalCustomerTransactionRuleName(context && context.nameListHitType);
}
return tag.ruleName || "";
};
const normalizeHitTags = (hitTags, context = {}) => {
if (!Array.isArray(hitTags)) {
return [];
}
const seen = new Set();
return hitTags
.map((tag) => ({
...tag,
ruleName: normalizeRuleName(tag, context),
}))
.filter((tag) => {
const key = `${tag.ruleCode || ""}|${tag.ruleName || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.sort((left, right) => {
const leftPriority = left.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE ? 0 : 1;
const rightPriority = right.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE ? 0 : 1;
return leftPriority - rightPriority;
});
};
const createEmptyDetailData = () => ({ const createEmptyDetailData = () => ({
bankStatementId: "", bankStatementId: "",
@@ -443,7 +497,7 @@ export default {
deep: true, deep: true,
handler(value) { handler(value) {
this.projectId = value && value.projectId ? value.projectId : null; this.projectId = value && value.projectId ? value.projectId : null;
this.currentSuspiciousType = value && value.suspiciousType ? value.suspiciousType : "ALL"; this.currentSuspiciousType = normalizeSuspiciousType(value && value.suspiciousType);
this.suspiciousPageNum = 1; this.suspiciousPageNum = 1;
this.suspiciousPageSize = 5; this.suspiciousPageSize = 5;
this.suspiciousTotal = Number(value && value.total) || 0; this.suspiciousTotal = Number(value && value.total) || 0;
@@ -603,7 +657,7 @@ export default {
return { return {
...detail, ...detail,
...row, ...row,
hitTags: normalizeHitTags(detail.hitTags), hitTags: normalizeHitTags(detail.hitTags, row),
}; };
} catch (error) { } catch (error) {
return { return {
@@ -647,7 +701,14 @@ export default {
this.detailVisible = true; this.detailVisible = true;
this.detailLoading = true; this.detailLoading = true;
try { try {
this.detailData = await this.fetchStatementDetail(row.bankStatementId, false); const detail = await this.fetchStatementDetail(row.bankStatementId, false);
this.detailData = {
...detail,
...row,
hitTags: row && Array.isArray(row.hitTags) && row.hitTags.length
? normalizeHitTags(row.hitTags, row)
: normalizeHitTags(detail.hitTags, row),
};
} catch (error) { } catch (error) {
this.detailData = createEmptyDetailData(); this.detailData = createEmptyDetailData();
this.$message.error("加载流水详情失败"); this.$message.error("加载流水详情失败");

View File

@@ -22,22 +22,7 @@
@evidence-confirm="$emit('evidence-confirm', $event)" @evidence-confirm="$emit('evidence-confirm', $event)"
/> />
<section class="graph-placeholder-card"> <fund-graph-section ref="fundGraphSection" />
<div class="graph-placeholder-header">
<div>
<div class="graph-placeholder-title">图谱外链展示</div>
<div class="graph-placeholder-subtitle">用于后续接入外链图谱页面</div>
</div>
<el-tag size="mini" type="info" effect="plain">占位中</el-tag>
</div>
<div class="graph-placeholder-body">
<div class="graph-placeholder-text">
当前卡片用于预留专项核查图谱入口后续接入外链地址后可在此直接跳转展示
</div>
<el-button type="primary" size="small" disabled>待接入</el-button>
</div>
</section>
</div> </div>
<div v-if="projectId" class="special-check-extended-wrapper"> <div v-if="projectId" class="special-check-extended-wrapper">
@@ -51,12 +36,14 @@ import { createSpecialCheckLoadedData, specialCheckStateData } from "./specialCh
import { getFamilyAssetLiabilityList } from "@/api/ccdi/projectSpecialCheck"; import { getFamilyAssetLiabilityList } from "@/api/ccdi/projectSpecialCheck";
import ExtendedQuerySection from "./ExtendedQuerySection"; import ExtendedQuerySection from "./ExtendedQuerySection";
import FamilyAssetLiabilitySection from "./FamilyAssetLiabilitySection"; import FamilyAssetLiabilitySection from "./FamilyAssetLiabilitySection";
import FundGraphSection from "./graph/FundGraphSection";
export default { export default {
name: "SpecialCheck", name: "SpecialCheck",
components: { components: {
ExtendedQuerySection, ExtendedQuerySection,
FamilyAssetLiabilitySection, FamilyAssetLiabilitySection,
FundGraphSection,
}, },
props: { props: {
projectId: { projectId: {
@@ -155,52 +142,6 @@ export default {
min-height: 400px; min-height: 400px;
} }
.graph-placeholder-card {
margin-top: 16px;
min-height: 500px;
padding: 20px;
background: #fff;
border: 1px solid var(--ccdi-border);
border-radius: 14px;
box-shadow: var(--ccdi-shadow);
}
.graph-placeholder-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.graph-placeholder-title {
font-size: 16px;
font-weight: 600;
color: var(--ccdi-text-primary);
}
.graph-placeholder-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ccdi-text-muted);
}
.graph-placeholder-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 18px;
padding: 16px 18px;
border: 1px dashed #d9e3ee;
background: #f8fbfe;
}
.graph-placeholder-text {
font-size: 14px;
line-height: 22px;
color: var(--ccdi-text-secondary);
}
.special-check-extended-wrapper { .special-check-extended-wrapper {
margin-top: 16px; margin-top: 16px;
} }

View File

@@ -126,10 +126,10 @@
type="textarea" type="textarea"
:rows="5" :rows="5"
:disabled="isProjectTagging || isProjectArchived" :disabled="isProjectTagging || isProjectArchived"
placeholder="支持逗号、中文逗号、换行分隔" placeholder="支持英文逗号分隔"
/> />
<div class="pull-bank-field-tip"> <div class="pull-bank-field-tip">
支持逗号中文逗号换行分隔文件解析结果会自动合并并去重 支持英文逗号分隔文件解析结果会自动合并并去重
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="身份证文件"> <el-form-item label="身份证文件">
@@ -423,7 +423,7 @@ export default {
resetPullBankInfoForm() { resetPullBankInfoForm() {
this.pullBankInfoForm = { this.pullBankInfoForm = {
idCardText: "", idCardText: "",
dateRange: [], dateRange: this.buildDefaultPullBankInfoDateRange(),
}; };
this.idCardFileList = []; this.idCardFileList = [];
this.parsingIdCardFile = false; this.parsingIdCardFile = false;
@@ -433,7 +433,7 @@ export default {
return Array.from( return Array.from(
new Set( new Set(
(text || "") (text || "")
.split(/[\n,]+/) .split(/,+/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
) )
@@ -503,11 +503,36 @@ export default {
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
return today; return today;
}, },
getPullBankInfoMinSelectableDate() {
return new Date(2025, 0, 1);
},
getPullBankInfoMaxSelectableDate() { getPullBankInfoMaxSelectableDate() {
const yesterday = this.getPullBankInfoTodayStart(); const yesterday = this.getPullBankInfoTodayStart();
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
return yesterday; return yesterday;
}, },
formatPullBankInfoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
},
buildDefaultPullBankInfoDateRange() {
const minSelectableDate = this.getPullBankInfoMinSelectableDate();
const maxSelectableDate = this.getPullBankInfoMaxSelectableDate();
const defaultStartDate = new Date(maxSelectableDate.getTime());
defaultStartDate.setFullYear(defaultStartDate.getFullYear() - 1);
if (defaultStartDate.getTime() < minSelectableDate.getTime()) {
return [
this.formatPullBankInfoDate(minSelectableDate),
this.formatPullBankInfoDate(maxSelectableDate),
];
}
return [
this.formatPullBankInfoDate(defaultStartDate),
this.formatPullBankInfoDate(maxSelectableDate),
];
},
parsePullBankInfoDate(dateValue) { parsePullBankInfoDate(dateValue) {
if (!dateValue) return null; if (!dateValue) return null;
const [year, month, day] = String(dateValue) const [year, month, day] = String(dateValue)
@@ -519,13 +544,23 @@ export default {
return new Date(year, month - 1, day); return new Date(year, month - 1, day);
}, },
isPullBankInfoDateDisabled(time) { isPullBankInfoDateDisabled(time) {
return time.getTime() >= this.getPullBankInfoTodayStart().getTime(); const minSelectableDate = this.getPullBankInfoMinSelectableDate();
const todayStart = this.getPullBankInfoTodayStart();
return (
time.getTime() < minSelectableDate.getTime() ||
time.getTime() >= todayStart.getTime()
);
}, },
hasInvalidPullBankInfoDateRange(dateRange) { hasInvalidPullBankInfoDateRange(dateRange) {
const minSelectableDate = this.getPullBankInfoMinSelectableDate();
const maxSelectableDate = this.getPullBankInfoMaxSelectableDate(); const maxSelectableDate = this.getPullBankInfoMaxSelectableDate();
return (dateRange || []).some((dateValue) => { return (dateRange || []).some((dateValue) => {
const date = this.parsePullBankInfoDate(dateValue); const date = this.parsePullBankInfoDate(dateValue);
return !date || date.getTime() > maxSelectableDate.getTime(); return (
!date ||
date.getTime() < minSelectableDate.getTime() ||
date.getTime() > maxSelectableDate.getTime()
);
}); });
}, },
buildFinalIdCardList() { buildFinalIdCardList() {
@@ -554,7 +589,7 @@ export default {
} }
if (this.hasInvalidPullBankInfoDateRange([startDate, endDate])) { if (this.hasInvalidPullBankInfoDateRange([startDate, endDate])) {
this.$message.warning("时间跨度最晚只能选择到昨天"); this.$message.warning("时间跨度仅支持 2025-01-01 至昨天");
return; return;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
-- ============================================================
-- 资金流图谱 MySQL 结果表
-- 说明:
-- 1. 表名和核心字段保持图谱平台 ODPS SQL 的必要结果表一致。
-- 2. 先从 ODPS 一次性同步已验证的行内流水图谱基座。
-- 3. 后续在纪检 MySQL 内从 ccdi_bank_statement 做增量去重插入。
-- 4. 不增加 project_id资金流图谱按全局 cret_no / 姓名查询。
-- 5. 只保留资金流图谱页面必需表,不依赖 lx_fund_flow_sum_edge。
-- 6. 真实资金边由后端基于 lx_fund_flow_detail_edge.trx_date 实时聚合。
-- 7. 手工资金流向作为主体级汇总边单独存储,不写入真实流水明细表。
-- 8. nullable 扩展字段只服务家庭关系、手工分析和增量来源。
-- ============================================================
CREATE TABLE IF NOT EXISTS lx_fund_flow_subject_node (
object_key VARCHAR(64) NOT NULL COMMENT '主体节点唯一标识通常为证件号MD5或代理主体MD5',
idnocfno VARCHAR(64) NULL COMMENT '证件号/身份证号',
name VARCHAR(255) NULL COMMENT '主体名称',
cinocsno VARCHAR(64) NULL COMMENT '客户内部号',
idno_type VARCHAR(64) NULL COMMENT '主体类型,如个人、企业、代理主体',
staff_id VARCHAR(64) NULL COMMENT '员工标识',
source_type VARCHAR(64) NULL COMMENT '来源类型',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_fund_flow_subject_idnocfno (idnocfno),
KEY idx_lx_fund_flow_subject_name (name),
KEY idx_lx_fund_flow_subject_source_type (source_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资金流图谱主体节点表';
CREATE TABLE IF NOT EXISTS lx_fund_flow_account_node (
object_key VARCHAR(64) NOT NULL COMMENT '账户节点唯一标识',
acc_no VARCHAR(128) NULL COMMENT '账号',
acc_name VARCHAR(255) NULL COMMENT '账户名称',
cinocsno VARCHAR(64) NULL COMMENT '客户内部号',
source VARCHAR(128) NULL COMMENT '来源',
acc_type VARCHAR(64) NULL COMMENT '账户类型如INTERNAL/EXTERNAL',
acc_idno VARCHAR(64) NULL COMMENT '开户证件号',
acc_status VARCHAR(64) NULL COMMENT '账户状态',
acc_date VARCHAR(32) NULL COMMENT '开户日期',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_fund_flow_account_acc_no (acc_no),
KEY idx_lx_fund_flow_account_acc_name (acc_name),
KEY idx_lx_fund_flow_account_acc_idno (acc_idno)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资金流图谱账户节点表';
CREATE TABLE IF NOT EXISTS lx_fund_flow_own_account_edge (
object_key VARCHAR(128) NOT NULL COMMENT '主体持有账户边唯一标识',
from_key VARCHAR(128) NOT NULL COMMENT '主体节点key格式 idno_node/{object_key}',
to_key VARCHAR(128) NOT NULL COMMENT '账户节点key格式 account_node/{object_key}',
acc_name VARCHAR(255) NULL COMMENT '账户名称',
acc_no VARCHAR(128) NULL COMMENT '账号',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_fund_flow_own_from_key (from_key),
KEY idx_lx_fund_flow_own_to_key (to_key),
KEY idx_lx_fund_flow_own_acc_no (acc_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资金流图谱主体持有账户边表';
CREATE TABLE IF NOT EXISTS lx_fund_flow_detail_edge (
object_key VARCHAR(64) NOT NULL COMMENT '账户层交易明细边唯一标识',
le_account_name VARCHAR(255) NULL COMMENT '本方账户名称',
le_account_no VARCHAR(128) NULL COMMENT '本方账号',
customer_account_no VARCHAR(128) NULL COMMENT '对手方账号',
customer_account_name VARCHAR(255) NULL COMMENT '对手方账户名称',
trx_date VARCHAR(32) NULL COMMENT '交易日期',
user_memo VARCHAR(1000) NULL COMMENT '交易摘要',
cash_type VARCHAR(255) NULL COMMENT '交易类型',
amount DECIMAL(19, 2) NULL COMMENT '交易金额',
flag VARCHAR(8) NULL COMMENT '收支方向1支出2收入',
amount_balance DECIMAL(19, 2) NULL COMMENT '交易后余额',
currency VARCHAR(32) NULL COMMENT '币种',
bank VARCHAR(255) NULL COMMENT '银行',
from_key VARCHAR(128) NOT NULL COMMENT '账户层起点key格式 account_node/{object_key}',
to_key VARCHAR(128) NOT NULL COMMENT '账户层终点key格式 account_node/{object_key}',
family_relation_type VARCHAR(64) NULL COMMENT '家庭关系类型,如配偶、父母、子女',
source_table VARCHAR(32) NULL COMMENT '流水来源基座可为空增量可为CCDI_BANK_STATEMENT/FIRST/LEVEL1',
penetrate_level INT NOT NULL DEFAULT 0 COMMENT '穿透层级一期为0',
bank_statement_id BIGINT NULL COMMENT '增量来源ccdi_bank_statement主键',
bank_trx_number VARCHAR(128) NULL COMMENT '银行流水号/交易流水号,如来源存在则记录',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_fund_flow_detail_from_to (from_key, to_key),
KEY idx_lx_fund_flow_detail_from_date (from_key, trx_date),
KEY idx_lx_fund_flow_detail_to_date (to_key, trx_date),
KEY idx_lx_fund_flow_detail_le_acc (le_account_no),
KEY idx_lx_fund_flow_detail_cp_acc (customer_account_no),
KEY idx_lx_fund_flow_detail_trx_date (trx_date),
KEY idx_lx_fund_flow_detail_statement (bank_statement_id),
KEY idx_lx_fund_flow_detail_family (family_relation_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资金流图谱账户层交易明细边表';
CREATE TABLE IF NOT EXISTS lx_fund_flow_manual_edge (
object_key VARCHAR(64) NOT NULL COMMENT '手工资金流向汇总边唯一标识',
from_object_key VARCHAR(64) NOT NULL COMMENT '起点主体object_key身份证MD5或手工主体MD5',
to_object_key VARCHAR(64) NOT NULL COMMENT '终点主体object_key身份证MD5或手工主体MD5',
from_name VARCHAR(255) NULL COMMENT '起点主体名称冗余',
to_name VARCHAR(255) NULL COMMENT '终点主体名称冗余',
amount DECIMAL(19, 2) NULL COMMENT '手工录入汇总金额',
transaction_count INT NOT NULL DEFAULT 1 COMMENT '手工录入笔数',
direction VARCHAR(8) NOT NULL COMMENT '资金方向1支出2收入',
relation_desc VARCHAR(255) NULL COMMENT '资金流向关系说明',
source_desc VARCHAR(500) NULL COMMENT '手工边来源说明',
remark VARCHAR(1000) NULL COMMENT '分析备注',
source_type VARCHAR(64) NOT NULL DEFAULT 'MANUAL' COMMENT '来源类型固定为MANUAL',
created_by VARCHAR(64) NULL COMMENT '创建人',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_by VARCHAR(64) NULL COMMENT '更新人',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_fund_flow_manual_from (from_object_key),
KEY idx_lx_fund_flow_manual_to (to_object_key),
KEY idx_lx_fund_flow_manual_pair_direction (from_object_key, to_object_key, direction),
KEY idx_lx_fund_flow_manual_source_type (source_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资金流图谱手工资金流向汇总边表';

View File

@@ -0,0 +1,553 @@
-- ============================================================
-- 资金流图谱测试数据
-- 说明:
-- 1. 仅用于开发联调,来源为当前库内 ccdi_bank_statement 与 ccdi_staff_fmy_relation。
-- 2. 不修改 ccdi_bank_statement 原始流水。
-- 3. 只写入资金流图谱必要四表:
-- lx_fund_flow_subject_node
-- lx_fund_flow_account_node
-- lx_fund_flow_own_account_edge
-- lx_fund_flow_detail_edge
-- 4. 测试数据 source_table/source_type/source 标记为 GRAPH_TEST可重复执行。
-- 5. 默认模拟“全部流水”查询,同时覆盖日期筛选、家庭关系标签和节点穿透。
-- ============================================================
-- 清理上一轮 GRAPH_TEST 测试数据。只清理测试标记数据,不影响 ODPS 基座。
DROP TEMPORARY TABLE IF EXISTS graph_test_old_node_key;
CREATE TEMPORARY TABLE graph_test_old_node_key AS
SELECT object_key
FROM lx_fund_flow_subject_node
WHERE source_type LIKE 'GRAPH_TEST%';
DROP TEMPORARY TABLE IF EXISTS graph_test_old_account_key;
CREATE TEMPORARY TABLE graph_test_old_account_key AS
SELECT object_key
FROM lx_fund_flow_account_node
WHERE source LIKE 'GRAPH_TEST%';
DELETE FROM lx_fund_flow_detail_edge
WHERE source_table = 'GRAPH_TEST';
DELETE FROM lx_fund_flow_own_account_edge
WHERE from_key IN (
SELECT CONCAT('idno_node/', object_key)
FROM graph_test_old_node_key
)
OR to_key IN (
SELECT CONCAT('account_node/', object_key)
FROM graph_test_old_account_key
);
DELETE FROM lx_fund_flow_account_node
WHERE object_key IN (
SELECT object_key
FROM graph_test_old_account_key
);
DELETE FROM lx_fund_flow_subject_node
WHERE object_key IN (
SELECT object_key
FROM graph_test_old_node_key
);
-- 选择一个同时具备流水和家庭关系的员工作为中心主体。
DROP TEMPORARY TABLE IF EXISTS graph_seed_subject;
CREATE TEMPORARY TABLE graph_seed_subject AS
SELECT
bs.cret_no AS person_id,
COALESCE(NULLIF(MAX(TRIM(staff.name)), ''), NULLIF(MAX(TRIM(bs.LE_ACCOUNT_NAME)), ''), '测试主体') AS person_name,
COUNT(1) AS trx_cnt
FROM ccdi_bank_statement bs
INNER JOIN ccdi_base_staff staff
ON staff.id_card = bs.cret_no
INNER JOIN ccdi_staff_fmy_relation r
ON r.person_id = bs.cret_no
AND r.status = 1
AND r.relation_cert_no IS NOT NULL
AND TRIM(r.relation_cert_no) <> ''
WHERE bs.cret_no IS NOT NULL
AND TRIM(bs.cret_no) <> ''
AND bs.LE_ACCOUNT_NO IS NOT NULL
AND TRIM(bs.LE_ACCOUNT_NO) <> ''
AND bs.CUSTOMER_ACCOUNT_NAME IS NOT NULL
AND TRIM(bs.CUSTOMER_ACCOUNT_NAME) NOT IN ('', '0')
AND (bs.AMOUNT_DR > 0 OR bs.AMOUNT_CR > 0)
AND TRIM(bs.cret_no) = '617673198109148314'
GROUP BY bs.cret_no
ORDER BY trx_cnt DESC
LIMIT 1;
-- 选 3 个家庭关系人,优先配偶。
DROP TEMPORARY TABLE IF EXISTS graph_seed_counterparty;
CREATE TEMPORARY TABLE graph_seed_counterparty (
cp_no INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
person_id VARCHAR(100) NOT NULL,
counterparty_type VARCHAR(32) NOT NULL,
relation_type VARCHAR(50) NULL,
counterparty_name VARCHAR(100) NOT NULL,
counterparty_cert_no VARCHAR(50) NULL,
counterparty_account_no VARCHAR(128) NOT NULL,
counterparty_subject_key VARCHAR(64) NOT NULL,
counterparty_account_key VARCHAR(64) NOT NULL,
can_expand TINYINT NOT NULL DEFAULT 0
);
INSERT INTO graph_seed_counterparty (
person_id,
counterparty_type,
relation_type,
counterparty_name,
counterparty_cert_no,
counterparty_account_no,
counterparty_subject_key,
counterparty_account_key,
can_expand
)
SELECT
person_id,
'FAMILY' AS counterparty_type,
relation_type,
relation_name AS counterparty_name,
relation_cert_no AS counterparty_cert_no,
CONCAT('FMY_', RIGHT(relation_cert_no, 12)) AS counterparty_account_no,
MD5(TRIM(relation_cert_no)) AS counterparty_subject_key,
MD5(CONCAT('FAMILY_ACCOUNT|', TRIM(relation_cert_no), '|', relation_name)) AS counterparty_account_key,
1 AS can_expand
FROM (
SELECT
r.person_id,
TRIM(r.relation_type) AS relation_type,
TRIM(r.relation_name) AS relation_name,
TRIM(r.relation_cert_no) AS relation_cert_no,
ROW_NUMBER() OVER (
PARTITION BY r.person_id
ORDER BY
CASE WHEN r.relation_type = '配偶' THEN 0 ELSE 1 END,
r.id
) AS rn
FROM ccdi_staff_fmy_relation r
INNER JOIN graph_seed_subject s
ON s.person_id = r.person_id
WHERE r.status = 1
AND r.relation_name IS NOT NULL
AND TRIM(r.relation_name) <> ''
AND r.relation_cert_no IS NOT NULL
AND TRIM(r.relation_cert_no) <> ''
) x
WHERE rn <= 3;
-- 再选 6 个普通对手方,模拟资金图里非家庭关系节点。
DROP TEMPORARY TABLE IF EXISTS graph_seed_family_name;
CREATE TEMPORARY TABLE graph_seed_family_name AS
SELECT DISTINCT counterparty_name
FROM graph_seed_counterparty;
INSERT INTO graph_seed_counterparty (
person_id,
counterparty_type,
relation_type,
counterparty_name,
counterparty_cert_no,
counterparty_account_no,
counterparty_subject_key,
counterparty_account_key,
can_expand
)
SELECT
person_id,
'NORMAL' AS counterparty_type,
NULL AS relation_type,
counterparty_name,
NULL AS counterparty_cert_no,
counterparty_account_no,
MD5(CONCAT('NAME_PROXY|', person_id, '|', counterparty_name)) AS counterparty_subject_key,
MD5(CONCAT('NAME_ONLY_ACC|', person_id, '|', counterparty_name, '|', counterparty_account_no)) AS counterparty_account_key,
0 AS can_expand
FROM (
SELECT
y.person_id,
y.counterparty_name,
COALESCE(y.raw_account_no, CONCAT('NAME_ONLY_', y.rn)) AS counterparty_account_no,
y.rn
FROM (
SELECT
s.person_id,
TRIM(bs.CUSTOMER_ACCOUNT_NAME) AS counterparty_name,
MAX(NULLIF(TRIM(bs.CUSTOMER_ACCOUNT_NO), '')) AS raw_account_no,
ROW_NUMBER() OVER (
ORDER BY COUNT(1) DESC, MAX(bs.bank_statement_id)
) AS rn
FROM graph_seed_subject s
INNER JOIN ccdi_bank_statement bs
ON bs.cret_no = s.person_id
LEFT JOIN graph_seed_family_name f
ON f.counterparty_name = TRIM(bs.CUSTOMER_ACCOUNT_NAME)
WHERE bs.CUSTOMER_ACCOUNT_NAME IS NOT NULL
AND TRIM(bs.CUSTOMER_ACCOUNT_NAME) NOT IN ('', '0')
AND f.counterparty_name IS NULL
AND (bs.AMOUNT_DR > 0 OR bs.AMOUNT_CR > 0)
GROUP BY
s.person_id,
TRIM(bs.CUSTOMER_ACCOUNT_NAME)
) y
) x
WHERE rn <= 6;
-- 取中心员工真实流水作为金额、方向、日期来源,并映射到家庭/普通对手方。
DROP TEMPORARY TABLE IF EXISTS graph_seed_statement;
CREATE TEMPORARY TABLE graph_seed_statement AS
SELECT *
FROM (
SELECT
y.*,
ROW_NUMBER() OVER (
PARTITION BY y.cp_no
ORDER BY y.flag, y.trx_date, y.bank_statement_id
) AS rn
FROM (
SELECT
x.*,
ROW_NUMBER() OVER (
PARTITION BY x.cp_no, x.flag
ORDER BY x.trx_date, x.bank_statement_id
) AS direction_rn
FROM (
SELECT
cp.cp_no,
bs.bank_statement_id,
bs.cret_no,
COALESCE(NULLIF(TRIM(subj.person_name), ''), NULLIF(TRIM(bs.LE_ACCOUNT_NAME), ''), '彭静勇') AS le_account_name,
TRIM(bs.LE_ACCOUNT_NO) AS le_account_no,
cp.counterparty_type,
cp.relation_type,
cp.counterparty_name AS customer_account_name,
cp.counterparty_account_no AS customer_account_no,
cp.counterparty_cert_no,
cp.counterparty_subject_key,
cp.counterparty_account_key,
cp.can_expand,
bs.TRX_DATE AS trx_date,
bs.USER_MEMO AS user_memo,
bs.CASH_TYPE AS cash_type,
CASE
WHEN bs.AMOUNT_DR > 0 THEN bs.AMOUNT_DR
WHEN bs.AMOUNT_CR > 0 THEN bs.AMOUNT_CR
ELSE 0
END AS amount,
CASE
WHEN bs.AMOUNT_DR > 0 THEN '1'
WHEN bs.AMOUNT_CR > 0 THEN '2'
ELSE NULL
END AS flag,
bs.AMOUNT_BALANCE AS amount_balance,
bs.CURRENCY AS currency,
bs.BANK AS bank,
bs.BANK_TRX_NUMBER AS bank_trx_number
FROM graph_seed_counterparty cp
INNER JOIN graph_seed_subject subj
ON subj.person_id = cp.person_id
INNER JOIN ccdi_bank_statement bs
ON bs.cret_no = cp.person_id
WHERE bs.cret_no IS NOT NULL
AND TRIM(bs.cret_no) <> ''
AND bs.LE_ACCOUNT_NO IS NOT NULL
AND TRIM(bs.LE_ACCOUNT_NO) <> ''
AND (bs.AMOUNT_DR > 0 OR bs.AMOUNT_CR > 0)
) x
WHERE x.flag IS NOT NULL
) y
WHERE (y.flag = '1' AND y.direction_rn <= 5)
OR (y.flag = '2' AND y.direction_rn <= 3)
) x
WHERE x.rn <= 8;
-- 主体点:员工本人。
INSERT IGNORE INTO lx_fund_flow_subject_node (
object_key,
idnocfno,
name,
cinocsno,
idno_type,
staff_id,
source_type
)
SELECT
MD5(TRIM(s.person_id)) AS object_key,
TRIM(s.person_id) AS idnocfno,
s.person_name AS name,
NULL AS cinocsno,
'个人' AS idno_type,
NULL AS staff_id,
'GRAPH_TEST_EMPLOYEE' AS source_type
FROM graph_seed_subject s;
-- 主体点:家庭关系人和普通对手方。
INSERT IGNORE INTO lx_fund_flow_subject_node (
object_key,
idnocfno,
name,
cinocsno,
idno_type,
staff_id,
source_type
)
SELECT DISTINCT
cp.counterparty_subject_key AS object_key,
cp.counterparty_cert_no AS idnocfno,
cp.counterparty_name AS name,
NULL AS cinocsno,
CASE WHEN cp.counterparty_cert_no IS NOT NULL THEN '个人' ELSE 'NAME_PROXY' END AS idno_type,
NULL AS staff_id,
CASE
WHEN cp.counterparty_type = 'FAMILY' THEN CONCAT('GRAPH_TEST_FAMILY_', cp.relation_type)
ELSE 'GRAPH_TEST_COUNTERPARTY'
END AS source_type
FROM graph_seed_counterparty cp;
-- 账户点:员工真实账户。
INSERT IGNORE INTO lx_fund_flow_account_node (
object_key,
acc_no,
acc_name,
cinocsno,
source,
acc_type,
acc_idno,
acc_status,
acc_date
)
SELECT DISTINCT
MD5(CONCAT(TRIM(s.le_account_no), TRIM(s.le_account_name))) AS object_key,
TRIM(s.le_account_no) AS acc_no,
TRIM(s.le_account_name) AS acc_name,
NULL AS cinocsno,
'GRAPH_TEST_BANK_STATEMENT' AS source,
'INTERNAL' AS acc_type,
TRIM(s.cret_no) AS acc_idno,
NULL AS acc_status,
NULL AS acc_date
FROM graph_seed_statement s;
-- 账户点:对手方账户。
INSERT IGNORE INTO lx_fund_flow_account_node (
object_key,
acc_no,
acc_name,
cinocsno,
source,
acc_type,
acc_idno,
acc_status,
acc_date
)
SELECT DISTINCT
s.counterparty_account_key AS object_key,
s.customer_account_no AS acc_no,
s.customer_account_name AS acc_name,
NULL AS cinocsno,
CASE
WHEN s.counterparty_type = 'FAMILY' THEN 'GRAPH_TEST_FAMILY_RELATION'
ELSE 'GRAPH_TEST_COUNTERPARTY'
END AS source,
'EXTERNAL' AS acc_type,
s.counterparty_cert_no AS acc_idno,
NULL AS acc_status,
NULL AS acc_date
FROM graph_seed_statement s;
-- 持有边:员工主体 -> 员工账户。
INSERT IGNORE INTO lx_fund_flow_own_account_edge (
object_key,
from_key,
to_key,
acc_name,
acc_no
)
SELECT DISTINCT
MD5(CONCAT(MD5(TRIM(s.cret_no)), '|', MD5(CONCAT(TRIM(s.le_account_no), TRIM(s.le_account_name))))) AS object_key,
CONCAT('idno_node/', MD5(TRIM(s.cret_no))) AS from_key,
CONCAT('account_node/', MD5(CONCAT(TRIM(s.le_account_no), TRIM(s.le_account_name)))) AS to_key,
TRIM(s.le_account_name) AS acc_name,
TRIM(s.le_account_no) AS acc_no
FROM graph_seed_statement s;
-- 持有边:对手方主体 -> 对手方账户。
INSERT IGNORE INTO lx_fund_flow_own_account_edge (
object_key,
from_key,
to_key,
acc_name,
acc_no
)
SELECT DISTINCT
MD5(CONCAT(s.counterparty_subject_key, '|', s.counterparty_account_key)) AS object_key,
CONCAT('idno_node/', s.counterparty_subject_key) AS from_key,
CONCAT('account_node/', s.counterparty_account_key) AS to_key,
s.customer_account_name AS acc_name,
s.customer_account_no AS acc_no
FROM graph_seed_statement s;
-- 账户层明细边:按流水方向生成 from_key / to_key并写入家庭关系类型。
INSERT IGNORE INTO lx_fund_flow_detail_edge (
object_key,
le_account_name,
le_account_no,
customer_account_no,
customer_account_name,
trx_date,
user_memo,
cash_type,
amount,
flag,
amount_balance,
currency,
bank,
from_key,
to_key,
family_relation_type,
source_table,
penetrate_level,
bank_statement_id,
bank_trx_number
)
SELECT
MD5(CONCAT('GRAPH_TEST|', s.bank_statement_id, '|', s.counterparty_subject_key, '|', s.rn)) AS object_key,
s.le_account_name,
s.le_account_no,
s.customer_account_no,
s.customer_account_name,
SUBSTRING(s.trx_date, 1, 19) AS trx_date,
s.user_memo,
s.cash_type,
s.amount,
s.flag,
s.amount_balance,
s.currency,
s.bank,
CASE
WHEN s.flag = '1'
THEN CONCAT('account_node/', MD5(CONCAT(TRIM(s.le_account_no), TRIM(s.le_account_name))))
ELSE CONCAT('account_node/', s.counterparty_account_key)
END AS from_key,
CASE
WHEN s.flag = '1'
THEN CONCAT('account_node/', s.counterparty_account_key)
ELSE CONCAT('account_node/', MD5(CONCAT(TRIM(s.le_account_no), TRIM(s.le_account_name))))
END AS to_key,
s.relation_type AS family_relation_type,
'GRAPH_TEST' AS source_table,
0 AS penetrate_level,
s.bank_statement_id,
s.bank_trx_number
FROM graph_seed_statement s
WHERE s.flag IS NOT NULL
AND s.amount > 0;
-- 一层穿透演示数据:用于验证点击“淘宝”后继续展开到商户节点。
SELECT object_key
INTO @taobao_node
FROM lx_fund_flow_subject_node
WHERE name = ('淘宝' COLLATE utf8mb4_general_ci)
ORDER BY updated_time DESC
LIMIT 1;
SELECT SUBSTRING_INDEX(own.to_key, '/', -1)
INTO @taobao_account_key
FROM lx_fund_flow_own_account_edge own
WHERE own.from_key = (CONCAT('idno_node/', @taobao_node) COLLATE utf8mb4_general_ci)
LIMIT 1;
SET @merchant_a_node := MD5('GRAPH_TEST_MERCHANT_A');
SET @merchant_b_node := MD5('GRAPH_TEST_MERCHANT_B');
SET @merchant_a_acc := MD5('GRAPH_TEST_MERCHANT_A_ACC');
SET @merchant_b_acc := MD5('GRAPH_TEST_MERCHANT_B_ACC');
INSERT INTO lx_fund_flow_subject_node (object_key, idnocfno, name, cinocsno, idno_type, staff_id, source_type)
VALUES
(@merchant_a_node, NULL, '商户A', NULL, 'NAME_PROXY', NULL, 'GRAPH_TEST_TRACE_COUNTERPARTY'),
(@merchant_b_node, NULL, '商户B', NULL, 'NAME_PROXY', NULL, 'GRAPH_TEST_TRACE_COUNTERPARTY')
ON DUPLICATE KEY UPDATE name = VALUES(name), source_type = VALUES(source_type), updated_time = CURRENT_TIMESTAMP;
INSERT INTO lx_fund_flow_account_node (object_key, acc_no, acc_name, source, acc_type)
VALUES
(@merchant_a_acc, 'TRACE-A-001', '商户A', 'GRAPH_TEST', 'EXTERNAL'),
(@merchant_b_acc, 'TRACE-B-001', '商户B', 'GRAPH_TEST', 'EXTERNAL')
ON DUPLICATE KEY UPDATE acc_name = VALUES(acc_name), source = VALUES(source), updated_time = CURRENT_TIMESTAMP;
INSERT INTO lx_fund_flow_own_account_edge (object_key, from_key, to_key, acc_name, acc_no)
VALUES
(MD5(CONCAT(@merchant_a_node, '|', @merchant_a_acc)), CONCAT('idno_node/', @merchant_a_node), CONCAT('account_node/', @merchant_a_acc), '商户A', 'TRACE-A-001'),
(MD5(CONCAT(@merchant_b_node, '|', @merchant_b_acc)), CONCAT('idno_node/', @merchant_b_node), CONCAT('account_node/', @merchant_b_acc), '商户B', 'TRACE-B-001')
ON DUPLICATE KEY UPDATE acc_name = VALUES(acc_name), acc_no = VALUES(acc_no), updated_time = CURRENT_TIMESTAMP;
INSERT INTO lx_fund_flow_detail_edge (
object_key,
le_account_name,
le_account_no,
customer_account_no,
customer_account_name,
trx_date,
user_memo,
cash_type,
amount,
flag,
amount_balance,
currency,
bank,
from_key,
to_key,
family_relation_type,
source_table,
penetrate_level,
bank_statement_id,
bank_trx_number
)
VALUES
(MD5('GRAPH_TEST_TAOBAO_TRACE_001'), '淘宝', '淘宝账户', 'TRACE-A-001', '商户A', '2025-03-20 09:18:00', '货款结算', '转账', 12880.00, '1', 0, 'CNY', '测试银行', CONCAT('account_node/', @taobao_account_key), CONCAT('account_node/', @merchant_a_acc), NULL, 'GRAPH_TEST', 1, NULL, 'TRACE001'),
(MD5('GRAPH_TEST_TAOBAO_TRACE_002'), '商户A', 'TRACE-A-001', '淘宝账户', '淘宝', '2025-03-20 16:42:00', '退款冲正', '退款', 3660.50, '2', 0, 'CNY', '测试银行', CONCAT('account_node/', @merchant_a_acc), CONCAT('account_node/', @taobao_account_key), NULL, 'GRAPH_TEST', 1, NULL, 'TRACE002'),
(MD5('GRAPH_TEST_TAOBAO_TRACE_003'), '淘宝', '淘宝账户', 'TRACE-B-001', '商户B', '2025-03-21 11:25:00', '服务费结算', '转账', 8290.30, '1', 0, 'CNY', '测试银行', CONCAT('account_node/', @taobao_account_key), CONCAT('account_node/', @merchant_b_acc), NULL, 'GRAPH_TEST', 1, NULL, 'TRACE003')
ON DUPLICATE KEY UPDATE
le_account_name = VALUES(le_account_name),
le_account_no = VALUES(le_account_no),
customer_account_no = VALUES(customer_account_no),
customer_account_name = VALUES(customer_account_name),
trx_date = VALUES(trx_date),
user_memo = VALUES(user_memo),
cash_type = VALUES(cash_type),
amount = VALUES(amount),
flag = VALUES(flag),
from_key = VALUES(from_key),
to_key = VALUES(to_key),
source_table = VALUES(source_table),
penetrate_level = VALUES(penetrate_level),
bank_trx_number = VALUES(bank_trx_number),
updated_time = CURRENT_TIMESTAMP;
SELECT 'lx_fund_flow_subject_node' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_fund_flow_subject_node
WHERE source_type LIKE 'GRAPH_TEST%'
UNION ALL
SELECT 'lx_fund_flow_account_node' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_fund_flow_account_node
WHERE source LIKE 'GRAPH_TEST%'
UNION ALL
SELECT 'lx_fund_flow_own_account_edge' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_fund_flow_own_account_edge e
WHERE e.from_key IN (
SELECT CONCAT('idno_node/', object_key)
FROM lx_fund_flow_subject_node
WHERE source_type LIKE 'GRAPH_TEST%'
)
OR e.to_key IN (
SELECT CONCAT('account_node/', object_key)
FROM lx_fund_flow_account_node
WHERE source LIKE 'GRAPH_TEST%'
)
UNION ALL
SELECT 'lx_fund_flow_detail_edge' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_fund_flow_detail_edge
WHERE source_table = 'GRAPH_TEST';
SELECT
s.person_id AS test_person_id,
s.person_name AS test_person_name,
MD5(TRIM(s.person_id)) AS test_object_key
FROM graph_seed_subject s;

View File

@@ -0,0 +1,129 @@
-- ============================================================
-- 关系图谱 MySQL 结果表
-- 说明:
-- 1. 表名和核心字段保持关系图谱平台 SQL 口径。
-- 2. lx_rel_node 为统一点表,家庭、工商等来源都落同一张点表。
-- 3. 边表按关系类型拆分,页面查询时由后端统一读取并合并展示。
-- 4. 家庭关系个人点 object_key 使用身份证号 MD5便于和资金图谱主体口径对齐。
-- 5. 家庭边按两端 object_key 无向去重,避免夫妻双方都是员工时出现两条边。
-- ============================================================
CREATE TABLE IF NOT EXISTS lx_rel_node (
object_key VARCHAR(128) NOT NULL COMMENT '节点唯一标识家庭个人为身份证MD5工商企业/股东/法人沿用图谱源主键',
node_name VARCHAR(255) NULL COMMENT '节点名称',
id_number VARCHAR(128) NULL COMMENT '证件号或统一社会信用代码',
subject_type VARCHAR(32) NULL COMMENT '主体类型,如个人、企业',
source_type VARCHAR(255) NULL COMMENT '来源类型,可聚合多个来源值',
detail_ref_type VARCHAR(64) NULL COMMENT '详情来源类型,如 STAFF_FAMILY、ENTERPRISE_BASE、ECI_COMPANY',
detail_ref_key VARCHAR(128) NULL COMMENT '详情来源主键如身份证号、统一社会信用代码、company_id',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_rel_node_name (node_name),
KEY idx_lx_rel_node_id_number (id_number),
KEY idx_lx_rel_node_subject_type (subject_type),
KEY idx_lx_rel_node_source_type (source_type),
KEY idx_lx_rel_node_detail_ref (detail_ref_type, detail_ref_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='关系图谱统一节点表';
-- 兼容已提前建过旧版 lx_rel_node 的生产环境IF NOT EXISTS 不会补新增字段。
SET @current_schema = DATABASE();
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_rel_node' AND COLUMN_NAME = 'detail_ref_type') = 0,
'ALTER TABLE lx_rel_node ADD COLUMN detail_ref_type VARCHAR(64) NULL COMMENT ''详情来源类型,如 STAFF_FAMILY、ENTERPRISE_BASE、ECI_COMPANY'' AFTER source_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_rel_node' AND COLUMN_NAME = 'detail_ref_key') = 0,
'ALTER TABLE lx_rel_node ADD COLUMN detail_ref_key VARCHAR(128) NULL COMMENT ''详情来源主键如身份证号、统一社会信用代码、company_id'' AFTER detail_ref_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_rel_node' AND INDEX_NAME = 'idx_lx_rel_node_detail_ref') = 0,
'ALTER TABLE lx_rel_node ADD KEY idx_lx_rel_node_detail_ref (detail_ref_type, detail_ref_key)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS lx_rel_family_edge (
object_key VARCHAR(64) NOT NULL COMMENT '家庭关系边唯一标识按两端节点无向MD5生成',
from_key VARCHAR(160) NOT NULL COMMENT '起点,格式 rel_node/{object_key}',
to_key VARCHAR(160) NOT NULL COMMENT '终点,格式 rel_node/{object_key}',
person_id VARCHAR(255) NULL COMMENT '员工身份证号;双向归并时可为多个证件号拼接',
relation_name VARCHAR(255) NULL COMMENT '关系人姓名;双向归并时可为多个姓名拼接',
relation_type VARCHAR(255) NULL COMMENT '关系类型;双向归并时可为多个关系拼接',
relation_cert_no VARCHAR(255) NULL COMMENT '关系人证件号;双向归并时可为多个证件号拼接',
gender VARCHAR(32) NULL COMMENT '性别',
birth_date VARCHAR(32) NULL COMMENT '出生日期',
relation_cert_type VARCHAR(64) NULL COMMENT '关系人证件类型',
mobile_phone1 VARCHAR(64) NULL COMMENT '手机号码1',
mobile_phone2 VARCHAR(64) NULL COMMENT '手机号码2',
wechat_no1 VARCHAR(128) NULL COMMENT '微信名称1',
wechat_no2 VARCHAR(128) NULL COMMENT '微信名称2',
wechat_no3 VARCHAR(128) NULL COMMENT '微信名称3',
contact_address VARCHAR(500) NULL COMMENT '联系地址',
annual_income DECIMAL(15,2) NULL COMMENT '家庭成员年收入(元/年)',
relation_desc VARCHAR(500) NULL COMMENT '关系详细描述',
status VARCHAR(16) NULL COMMENT '状态',
effective_date VARCHAR(32) NULL COMMENT '关系生效日期',
invalid_date VARCHAR(32) NULL COMMENT '关系失效日期',
remark TEXT NULL COMMENT '备注',
data_source VARCHAR(64) NULL COMMENT '数据来源',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_rel_family_from_key (from_key),
KEY idx_lx_rel_family_to_key (to_key),
KEY idx_lx_rel_family_person_id (person_id),
KEY idx_lx_rel_family_relation_cert_no (relation_cert_no),
KEY idx_lx_rel_family_relation_type (relation_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='关系图谱家庭关系边表';
CREATE TABLE IF NOT EXISTS lx_rel_stock_edge (
object_key VARCHAR(191) NOT NULL COMMENT '股东关系边唯一标识沿用图谱SQL的 company_id + p_key_no + stock_type 口径',
from_key VARCHAR(160) NOT NULL COMMENT '起点股东节点key格式 rel_node/{p_key_no}',
to_key VARCHAR(160) NOT NULL COMMENT '终点企业节点key格式 rel_node/{company_id}',
company_name VARCHAR(255) NULL COMMENT '企业名称',
stock_name VARCHAR(255) NULL COMMENT '股东',
stock_type VARCHAR(64) NULL COMMENT '股东类型',
stock_percent VARCHAR(64) NULL COMMENT '持股比例',
should_capi VARCHAR(128) NULL COMMENT '认缴出资额',
should_capi_value VARCHAR(128) NULL COMMENT '认缴出资额数值',
should_capi_unit VARCHAR(64) NULL COMMENT '认缴出资额单位',
shoud_date VARCHAR(64) NULL COMMENT '认缴出资日期',
p_key_no VARCHAR(128) NULL COMMENT '股东keyno',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_rel_stock_from_key (from_key),
KEY idx_lx_rel_stock_to_key (to_key),
KEY idx_lx_rel_stock_p_key_no (p_key_no),
KEY idx_lx_rel_stock_type (stock_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='关系图谱股东持股边表';
CREATE TABLE IF NOT EXISTS lx_rel_represent_edge (
object_key VARCHAR(191) NOT NULL COMMENT '法人关系边唯一标识沿用图谱SQL的 company_id + oper_key_no 口径',
from_key VARCHAR(160) NOT NULL COMMENT '起点法人节点key格式 rel_node/{oper_key_no}',
to_key VARCHAR(160) NOT NULL COMMENT '终点企业节点key格式 rel_node/{company_id}',
oper_name VARCHAR(255) NULL COMMENT '法定代表人姓名',
oper_key_no VARCHAR(128) NULL COMMENT '法定代表人keyno',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key),
KEY idx_lx_rel_represent_from_key (from_key),
KEY idx_lx_rel_represent_to_key (to_key),
KEY idx_lx_rel_represent_oper_key_no (oper_key_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='关系图谱法定代表人边表';

View File

@@ -0,0 +1,476 @@
-- ============================================================
-- 关系图谱 MySQL 构建脚本
-- 说明:
-- 1. 执行前先执行 03_lx_relation_graph_mysql_ddl.sql。
-- 2. 本脚本优先写入家庭关系点边,再写入工商股东/法人点边。
-- 3. 点表和边表通过主键与聚合逻辑保证去重,可重复执行。
-- 4. 家庭个人点 object_key = MD5(身份证号)。
-- 5. 家庭边按两端 object_key 无向归并,夫妻双方都是员工时只保留一条边。
-- 6. 工商源表沿用现有关系图谱 SQLsjfx_pro.t_eci_company_orc / sjfx_pro.t_eci_partner_orc。
-- ============================================================
SET SESSION group_concat_max_len = 8192;
-- ============================================================
-- Step 1. 家庭关系去重
-- 仅有证件号的家庭成员入图;无证件号无法生成身份证 MD5本轮不入图。
-- ============================================================
DROP TEMPORARY TABLE IF EXISTS tmp_rel_family_dedup;
CREATE TEMPORARY TABLE tmp_rel_family_dedup AS
SELECT
id,
TRIM(person_id) AS person_id,
TRIM(relation_type) AS relation_type,
TRIM(relation_name) AS relation_name,
TRIM(gender) AS gender,
DATE_FORMAT(birth_date, '%Y-%m-%d') AS birth_date,
TRIM(relation_cert_type) AS relation_cert_type,
TRIM(relation_cert_no) AS relation_cert_no,
TRIM(mobile_phone1) AS mobile_phone1,
TRIM(mobile_phone2) AS mobile_phone2,
TRIM(wechat_no1) AS wechat_no1,
TRIM(wechat_no2) AS wechat_no2,
TRIM(wechat_no3) AS wechat_no3,
TRIM(contact_address) AS contact_address,
annual_income,
TRIM(relation_desc) AS relation_desc,
status,
DATE_FORMAT(effective_date, '%Y-%m-%d %H:%i:%s') AS effective_date,
DATE_FORMAT(invalid_date, '%Y-%m-%d %H:%i:%s') AS invalid_date,
TRIM(remark) AS remark,
TRIM(data_source) AS data_source,
create_time,
update_time
FROM (
SELECT
r.*,
ROW_NUMBER() OVER (
PARTITION BY
TRIM(r.person_id),
TRIM(r.relation_type),
TRIM(r.relation_name),
TRIM(r.relation_cert_no)
ORDER BY r.update_time DESC, r.create_time DESC, r.id DESC
) AS rn
FROM ccdi_staff_fmy_relation r
WHERE r.status = 1
AND r.person_id IS NOT NULL
AND TRIM(r.person_id) <> ''
AND r.relation_type IS NOT NULL
AND TRIM(r.relation_type) <> ''
AND r.relation_name IS NOT NULL
AND TRIM(r.relation_name) <> ''
AND r.relation_cert_no IS NOT NULL
AND TRIM(r.relation_cert_no) <> ''
) x
WHERE x.rn = 1;
-- ============================================================
-- Step 2. 家庭关系点:员工本人优先入图
-- ============================================================
INSERT INTO lx_rel_node (
object_key,
node_name,
id_number,
subject_type,
source_type,
detail_ref_type,
detail_ref_key
)
SELECT
MD5(f.person_id) AS object_key,
COALESCE(NULLIF(MAX(TRIM(staff.name)), ''), f.person_id) AS node_name,
f.person_id AS id_number,
'个人' AS subject_type,
'员工家庭关系-员工' AS source_type,
'STAFF_FAMILY' AS detail_ref_type,
f.person_id AS detail_ref_key
FROM tmp_rel_family_dedup f
LEFT JOIN ccdi_base_staff staff
ON staff.id_card = f.person_id
GROUP BY f.person_id
ON DUPLICATE KEY UPDATE
node_name = COALESCE(NULLIF(VALUES(node_name), ''), lx_rel_node.node_name),
id_number = COALESCE(NULLIF(VALUES(id_number), ''), lx_rel_node.id_number),
subject_type = COALESCE(NULLIF(lx_rel_node.subject_type, ''), VALUES(subject_type)),
source_type = CASE
WHEN lx_rel_node.source_type IS NULL OR lx_rel_node.source_type = '' THEN VALUES(source_type)
WHEN FIND_IN_SET(VALUES(source_type), lx_rel_node.source_type) > 0 THEN lx_rel_node.source_type
ELSE CONCAT(lx_rel_node.source_type, ',', VALUES(source_type))
END,
detail_ref_type = COALESCE(NULLIF(lx_rel_node.detail_ref_type, ''), VALUES(detail_ref_type)),
detail_ref_key = COALESCE(NULLIF(lx_rel_node.detail_ref_key, ''), VALUES(detail_ref_key)),
updated_time = CURRENT_TIMESTAMP;
-- ============================================================
-- Step 3. 家庭关系点:家属入图
-- 如果家属同时也是员工主键一致source_type 会合并。
-- ============================================================
INSERT INTO lx_rel_node (
object_key,
node_name,
id_number,
subject_type,
source_type,
detail_ref_type,
detail_ref_key
)
SELECT
MD5(f.relation_cert_no) AS object_key,
COALESCE(NULLIF(MAX(TRIM(staff.name)), ''), MAX(f.relation_name)) AS node_name,
f.relation_cert_no AS id_number,
'个人' AS subject_type,
'员工家庭关系-成员' AS source_type,
'STAFF_FAMILY' AS detail_ref_type,
f.relation_cert_no AS detail_ref_key
FROM tmp_rel_family_dedup f
LEFT JOIN ccdi_base_staff staff
ON staff.id_card = f.relation_cert_no
GROUP BY f.relation_cert_no
ON DUPLICATE KEY UPDATE
node_name = COALESCE(NULLIF(VALUES(node_name), ''), lx_rel_node.node_name),
id_number = COALESCE(NULLIF(VALUES(id_number), ''), lx_rel_node.id_number),
subject_type = COALESCE(NULLIF(lx_rel_node.subject_type, ''), VALUES(subject_type)),
source_type = CASE
WHEN lx_rel_node.source_type IS NULL OR lx_rel_node.source_type = '' THEN VALUES(source_type)
WHEN FIND_IN_SET(VALUES(source_type), lx_rel_node.source_type) > 0 THEN lx_rel_node.source_type
ELSE CONCAT(lx_rel_node.source_type, ',', VALUES(source_type))
END,
detail_ref_type = COALESCE(NULLIF(lx_rel_node.detail_ref_type, ''), VALUES(detail_ref_type)),
detail_ref_key = COALESCE(NULLIF(lx_rel_node.detail_ref_key, ''), VALUES(detail_ref_key)),
updated_time = CURRENT_TIMESTAMP;
-- ============================================================
-- Step 4. 家庭关系边:按两端 object_key 无向归并
-- ============================================================
DROP TEMPORARY TABLE IF EXISTS tmp_rel_family_edge_rows;
CREATE TEMPORARY TABLE tmp_rel_family_edge_rows AS
SELECT
MD5(f.person_id) AS person_object_key,
MD5(f.relation_cert_no) AS relation_object_key,
LEAST(MD5(f.person_id), MD5(f.relation_cert_no)) AS from_object_key,
GREATEST(MD5(f.person_id), MD5(f.relation_cert_no)) AS to_object_key,
f.*
FROM tmp_rel_family_dedup f
WHERE f.person_id <> f.relation_cert_no;
INSERT INTO lx_rel_family_edge (
object_key,
from_key,
to_key,
person_id,
relation_name,
relation_type,
relation_cert_no,
gender,
birth_date,
relation_cert_type,
mobile_phone1,
mobile_phone2,
wechat_no1,
wechat_no2,
wechat_no3,
contact_address,
annual_income,
relation_desc,
status,
effective_date,
invalid_date,
remark,
data_source
)
SELECT
MD5(CONCAT('family|', from_object_key, '|', to_object_key)) AS object_key,
CONCAT('rel_node/', from_object_key) AS from_key,
CONCAT('rel_node/', to_object_key) AS to_key,
GROUP_CONCAT(DISTINCT person_id ORDER BY person_id SEPARATOR '/') AS person_id,
GROUP_CONCAT(DISTINCT relation_name ORDER BY relation_name SEPARATOR '/') AS relation_name,
GROUP_CONCAT(DISTINCT relation_type ORDER BY relation_type SEPARATOR '/') AS relation_type,
GROUP_CONCAT(DISTINCT relation_cert_no ORDER BY relation_cert_no SEPARATOR '/') AS relation_cert_no,
GROUP_CONCAT(DISTINCT NULLIF(gender, '') ORDER BY gender SEPARATOR '/') AS gender,
GROUP_CONCAT(DISTINCT NULLIF(birth_date, '') ORDER BY birth_date SEPARATOR '/') AS birth_date,
GROUP_CONCAT(DISTINCT NULLIF(relation_cert_type, '') ORDER BY relation_cert_type SEPARATOR '/') AS relation_cert_type,
GROUP_CONCAT(DISTINCT NULLIF(mobile_phone1, '') ORDER BY mobile_phone1 SEPARATOR '/') AS mobile_phone1,
GROUP_CONCAT(DISTINCT NULLIF(mobile_phone2, '') ORDER BY mobile_phone2 SEPARATOR '/') AS mobile_phone2,
GROUP_CONCAT(DISTINCT NULLIF(wechat_no1, '') ORDER BY wechat_no1 SEPARATOR '/') AS wechat_no1,
GROUP_CONCAT(DISTINCT NULLIF(wechat_no2, '') ORDER BY wechat_no2 SEPARATOR '/') AS wechat_no2,
GROUP_CONCAT(DISTINCT NULLIF(wechat_no3, '') ORDER BY wechat_no3 SEPARATOR '/') AS wechat_no3,
GROUP_CONCAT(DISTINCT NULLIF(contact_address, '') ORDER BY contact_address SEPARATOR '/') AS contact_address,
MAX(annual_income) AS annual_income,
GROUP_CONCAT(DISTINCT NULLIF(relation_desc, '') ORDER BY relation_desc SEPARATOR '/') AS relation_desc,
GROUP_CONCAT(DISTINCT CAST(status AS CHAR) ORDER BY status SEPARATOR '/') AS status,
GROUP_CONCAT(DISTINCT NULLIF(effective_date, '') ORDER BY effective_date SEPARATOR '/') AS effective_date,
GROUP_CONCAT(DISTINCT NULLIF(invalid_date, '') ORDER BY invalid_date SEPARATOR '/') AS invalid_date,
GROUP_CONCAT(DISTINCT NULLIF(remark, '') ORDER BY remark SEPARATOR '/') AS remark,
GROUP_CONCAT(DISTINCT NULLIF(data_source, '') ORDER BY data_source SEPARATOR '/') AS data_source
FROM tmp_rel_family_edge_rows
GROUP BY from_object_key, to_object_key
ON DUPLICATE KEY UPDATE
from_key = VALUES(from_key),
to_key = VALUES(to_key),
person_id = VALUES(person_id),
relation_name = VALUES(relation_name),
relation_type = VALUES(relation_type),
relation_cert_no = VALUES(relation_cert_no),
gender = VALUES(gender),
birth_date = VALUES(birth_date),
relation_cert_type = VALUES(relation_cert_type),
mobile_phone1 = VALUES(mobile_phone1),
mobile_phone2 = VALUES(mobile_phone2),
wechat_no1 = VALUES(wechat_no1),
wechat_no2 = VALUES(wechat_no2),
wechat_no3 = VALUES(wechat_no3),
contact_address = VALUES(contact_address),
annual_income = VALUES(annual_income),
relation_desc = VALUES(relation_desc),
status = VALUES(status),
effective_date = VALUES(effective_date),
invalid_date = VALUES(invalid_date),
remark = VALUES(remark),
data_source = VALUES(data_source),
updated_time = CURRENT_TIMESTAMP;
-- ============================================================
-- Step 5. 工商公司最新快照
-- 如果生产 MySQL 未同步 sjfx_pro.t_eci_company_orc / t_eci_partner_orc可只执行 Step 1-4。
-- ============================================================
DROP TEMPORARY TABLE IF EXISTS tmp_rel_company_latest;
CREATE TEMPORARY TABLE tmp_rel_company_latest AS
SELECT
TRIM(key_no) AS key_no,
TRIM(company_id) AS company_id,
TRIM(company_name) AS company_name,
TRIM(credit_code) AS credit_code,
TRIM(oper_key_no) AS oper_key_no,
TRIM(oper_name) AS oper_name,
check_date
FROM (
SELECT
c.*,
ROW_NUMBER() OVER (
PARTITION BY TRIM(c.credit_code)
ORDER BY SUBSTR(c.check_date, 1, 10) DESC, c.updated_date DESC
) AS rn
FROM sjfx_pro.t_eci_company_orc c
WHERE LENGTH(TRIM(c.credit_code)) = 18
AND c.isadd <> '-1'
AND c.company_id IS NOT NULL
AND TRIM(c.company_id) <> ''
AND c.key_no IS NOT NULL
AND TRIM(c.key_no) <> ''
AND c.company_name IS NOT NULL
AND TRIM(c.company_name) <> ''
) x
WHERE x.rn = 1;
DROP TEMPORARY TABLE IF EXISTS tmp_rel_partner_dedup;
CREATE TEMPORARY TABLE tmp_rel_partner_dedup AS
SELECT
TRIM(key_no) AS key_no,
TRIM(company_id) AS company_id,
TRIM(company_name) AS company_name,
TRIM(stock_name) AS stock_name,
TRIM(stock_type) AS stock_type,
TRIM(stock_percent) AS stock_percent,
TRIM(should_capi) AS should_capi,
TRIM(should_capi_value) AS should_capi_value,
TRIM(should_capi_unit) AS should_capi_unit,
TRIM(shoud_date) AS shoud_date,
TRIM(p_key_no) AS p_key_no,
dates
FROM (
SELECT
p.*,
ROW_NUMBER() OVER (
PARTITION BY TRIM(p.company_id), TRIM(p.p_key_no), TRIM(p.stock_type)
ORDER BY p.dates DESC, p.capi_date DESC, p.shoud_date DESC
) AS rn
FROM sjfx_pro.t_eci_partner_orc p
WHERE p.isadd <> '-1'
AND p.company_id IS NOT NULL
AND TRIM(p.company_id) <> ''
AND p.key_no IS NOT NULL
AND TRIM(p.key_no) <> ''
AND p.p_key_no IS NOT NULL
AND TRIM(p.p_key_no) <> ''
AND p.stock_name IS NOT NULL
AND TRIM(p.stock_name) <> ''
AND p.stock_type IN ('企业股东', '自然人股东')
) x
WHERE x.rn = 1;
-- ============================================================
-- Step 6. 工商点入 lx_rel_node
-- ============================================================
INSERT INTO lx_rel_node (
object_key,
node_name,
id_number,
subject_type,
source_type,
detail_ref_type,
detail_ref_key
)
SELECT
node_key AS object_key,
MAX(node_name) AS node_name,
MAX(id_number) AS id_number,
MAX(subject_type) AS subject_type,
GROUP_CONCAT(DISTINCT source_type ORDER BY source_type SEPARATOR ',') AS source_type,
MAX(detail_ref_type) AS detail_ref_type,
MAX(detail_ref_key) AS detail_ref_key
FROM (
SELECT
c.company_id AS node_key,
c.company_name AS node_name,
c.credit_code AS id_number,
'企业' AS subject_type,
'工商企业' AS source_type,
'ECI_COMPANY' AS detail_ref_type,
c.company_id AS detail_ref_key
FROM tmp_rel_company_latest c
UNION ALL
SELECT
p.p_key_no AS node_key,
p.stock_name AS node_name,
NULL AS id_number,
'企业' AS subject_type,
'企业股东' AS source_type,
'ECI_PARTNER' AS detail_ref_type,
p.p_key_no AS detail_ref_key
FROM tmp_rel_partner_dedup p
WHERE p.stock_type = '企业股东'
UNION ALL
SELECT
p.p_key_no AS node_key,
p.stock_name AS node_name,
NULL AS id_number,
'个人' AS subject_type,
'自然人股东' AS source_type,
'ECI_PARTNER' AS detail_ref_type,
p.p_key_no AS detail_ref_key
FROM tmp_rel_partner_dedup p
WHERE p.stock_type = '自然人股东'
UNION ALL
SELECT
c.oper_key_no AS node_key,
c.oper_name AS node_name,
NULL AS id_number,
'个人' AS subject_type,
'法定代表人' AS source_type,
'ECI_COMPANY_OPERATOR' AS detail_ref_type,
c.oper_key_no AS detail_ref_key
FROM tmp_rel_company_latest c
WHERE c.oper_key_no IS NOT NULL
AND c.oper_key_no <> ''
AND c.oper_name IS NOT NULL
AND c.oper_name <> ''
) n
WHERE node_key IS NOT NULL
AND node_key <> ''
GROUP BY node_key
ON DUPLICATE KEY UPDATE
node_name = COALESCE(NULLIF(VALUES(node_name), ''), lx_rel_node.node_name),
id_number = COALESCE(NULLIF(VALUES(id_number), ''), lx_rel_node.id_number),
subject_type = COALESCE(NULLIF(VALUES(subject_type), ''), lx_rel_node.subject_type),
source_type = CASE
WHEN lx_rel_node.source_type IS NULL OR lx_rel_node.source_type = '' THEN VALUES(source_type)
WHEN VALUES(source_type) IS NULL OR VALUES(source_type) = '' THEN lx_rel_node.source_type
WHEN LOCATE(VALUES(source_type), lx_rel_node.source_type) > 0 THEN lx_rel_node.source_type
ELSE CONCAT_WS(',', lx_rel_node.source_type, VALUES(source_type))
END,
detail_ref_type = COALESCE(NULLIF(lx_rel_node.detail_ref_type, ''), VALUES(detail_ref_type)),
detail_ref_key = COALESCE(NULLIF(lx_rel_node.detail_ref_key, ''), VALUES(detail_ref_key)),
updated_time = CURRENT_TIMESTAMP;
-- ============================================================
-- Step 7. 工商股东边入 lx_rel_stock_edge
-- ============================================================
INSERT INTO lx_rel_stock_edge (
object_key,
from_key,
to_key,
company_name,
stock_name,
stock_type,
stock_percent,
should_capi,
should_capi_value,
should_capi_unit,
shoud_date,
p_key_no
)
SELECT
CONCAT(c.company_id, p.p_key_no, p.stock_type) AS object_key,
CONCAT('rel_node/', p.p_key_no) AS from_key,
CONCAT('rel_node/', c.company_id) AS to_key,
p.company_name,
p.stock_name,
p.stock_type,
p.stock_percent,
p.should_capi,
p.should_capi_value,
p.should_capi_unit,
p.shoud_date,
p.p_key_no
FROM tmp_rel_partner_dedup p
INNER JOIN tmp_rel_company_latest c
ON c.company_id = p.company_id
ON DUPLICATE KEY UPDATE
from_key = VALUES(from_key),
to_key = VALUES(to_key),
company_name = VALUES(company_name),
stock_name = VALUES(stock_name),
stock_type = VALUES(stock_type),
stock_percent = VALUES(stock_percent),
should_capi = VALUES(should_capi),
should_capi_value = VALUES(should_capi_value),
should_capi_unit = VALUES(should_capi_unit),
shoud_date = VALUES(shoud_date),
p_key_no = VALUES(p_key_no),
updated_time = CURRENT_TIMESTAMP;
-- ============================================================
-- Step 8. 工商法人边入 lx_rel_represent_edge
-- ============================================================
INSERT INTO lx_rel_represent_edge (
object_key,
from_key,
to_key,
oper_name,
oper_key_no
)
SELECT
CONCAT(c.company_id, c.oper_key_no) AS object_key,
CONCAT('rel_node/', c.oper_key_no) AS from_key,
CONCAT('rel_node/', c.company_id) AS to_key,
c.oper_name,
c.oper_key_no
FROM tmp_rel_company_latest c
WHERE c.oper_key_no IS NOT NULL
AND c.oper_key_no <> ''
AND c.oper_name IS NOT NULL
AND c.oper_name <> ''
ON DUPLICATE KEY UPDATE
from_key = VALUES(from_key),
to_key = VALUES(to_key),
oper_name = VALUES(oper_name),
oper_key_no = VALUES(oper_key_no),
updated_time = CURRENT_TIMESTAMP;
-- ============================================================
-- Step 9. 结果检查
-- ============================================================
SELECT 'lx_rel_node' AS table_name, COUNT(1) AS row_cnt FROM lx_rel_node
UNION ALL
SELECT 'lx_rel_family_edge' AS table_name, COUNT(1) AS row_cnt FROM lx_rel_family_edge
UNION ALL
SELECT 'lx_rel_stock_edge' AS table_name, COUNT(1) AS row_cnt FROM lx_rel_stock_edge
UNION ALL
SELECT 'lx_rel_represent_edge' AS table_name, COUNT(1) AS row_cnt FROM lx_rel_represent_edge;

View File

@@ -0,0 +1,288 @@
-- ============================================================
-- 关系图谱固定测试数据
-- 说明:
-- 1. 仅用于开发联调,执行前先执行 03_lx_relation_graph_mysql_ddl.sql。
-- 2. 只写入 lx_rel_node / lx_rel_family_edge / lx_rel_stock_edge / lx_rel_represent_edge。
-- 3. 测试数据以 GRAPH_TEST 标记,可重复执行。
-- 4. 覆盖家庭关系、股东持股、法定代表人三类关系。
-- 5. 家庭关系按身份证 MD5 成点,夫妻双方只保留一条配偶边。
-- ============================================================
SET @staff_a_id := '330101198001010011';
SET @staff_b_id := '330101198202020022';
SET @child_id := '330101201206010033';
SET @staff_a_key := MD5(@staff_a_id);
SET @staff_b_key := MD5(@staff_b_id);
SET @child_key := MD5(@child_id);
SET @family_spouse_from := LEAST(@staff_a_key, @staff_b_key);
SET @family_spouse_to := GREATEST(@staff_a_key, @staff_b_key);
SET @family_child_from := LEAST(@staff_a_key, @child_key);
SET @family_child_to := GREATEST(@staff_a_key, @child_key);
SET @company_key := 'GRAPH_TEST_COMPANY_001';
SET @company_credit_code := '91330100GRAPH00001';
SET @person_holder_key := 'GRAPH_TEST_PERSON_HOLDER_001';
SET @spouse_holder_key := 'GRAPH_TEST_PERSON_SPOUSE_HOLDER_001';
SET @enterprise_holder_key := 'GRAPH_TEST_ENTERPRISE_HOLDER_001';
SET @legal_key := 'GRAPH_TEST_LEGAL_001';
-- 清理上一轮 GRAPH_TEST 数据。只清理测试标记数据,不影响生产/基座数据。
DELETE FROM lx_rel_family_edge
WHERE data_source = 'GRAPH_TEST';
DELETE FROM lx_rel_stock_edge
WHERE object_key LIKE 'GRAPH_TEST_%';
DELETE FROM lx_rel_represent_edge
WHERE object_key LIKE 'GRAPH_TEST_%';
DELETE FROM lx_rel_node
WHERE source_type LIKE 'GRAPH_TEST%';
-- 测试点:家庭关系个人。
INSERT INTO lx_rel_node (
object_key,
node_name,
id_number,
subject_type,
source_type,
detail_ref_type,
detail_ref_key
)
VALUES
(@staff_a_key, '关系图谱测试员工甲', @staff_a_id, '个人', 'GRAPH_TEST_员工家庭关系-员工', 'STAFF_FAMILY', @staff_a_id),
(@staff_b_key, '关系图谱测试员工乙', @staff_b_id, '个人', 'GRAPH_TEST_员工家庭关系-员工,GRAPH_TEST_员工家庭关系-成员', 'STAFF_FAMILY', @staff_b_id),
(@child_key, '关系图谱测试子女', @child_id, '个人', 'GRAPH_TEST_员工家庭关系-成员', 'STAFF_FAMILY', @child_id)
ON DUPLICATE KEY UPDATE
node_name = VALUES(node_name),
id_number = VALUES(id_number),
subject_type = VALUES(subject_type),
source_type = VALUES(source_type),
detail_ref_type = VALUES(detail_ref_type),
detail_ref_key = VALUES(detail_ref_key),
updated_time = CURRENT_TIMESTAMP;
-- 测试边:家庭关系,夫妻双方只落一条边。
INSERT INTO lx_rel_family_edge (
object_key,
from_key,
to_key,
person_id,
relation_name,
relation_type,
relation_cert_no,
gender,
birth_date,
relation_cert_type,
mobile_phone1,
mobile_phone2,
wechat_no1,
wechat_no2,
wechat_no3,
contact_address,
annual_income,
relation_desc,
status,
effective_date,
invalid_date,
remark,
data_source
)
VALUES
(MD5(CONCAT('family|', @family_spouse_from, '|', @family_spouse_to)),
CONCAT('rel_node/', @family_spouse_from),
CONCAT('rel_node/', @family_spouse_to),
CONCAT(@staff_a_id, '/', @staff_b_id),
'关系图谱测试员工甲/关系图谱测试员工乙',
'配偶',
CONCAT(@staff_a_id, '/', @staff_b_id),
'F/M',
'1980-01-01/1982-02-02',
'身份证',
'13800000001/13800000002',
NULL,
NULL,
NULL,
NULL,
'杭州市测试区家庭路1号',
320000.00,
'夫妻双方都是员工,归并为一条家庭边',
'1',
'2024-01-01 00:00:00',
NULL,
'GRAPH_TEST',
'GRAPH_TEST'),
(MD5(CONCAT('family|', @family_child_from, '|', @family_child_to)),
CONCAT('rel_node/', @family_child_from),
CONCAT('rel_node/', @family_child_to),
@staff_a_id,
'关系图谱测试子女',
'子女',
@child_id,
'M',
'2012-06-01',
'身份证',
'13800000003',
NULL,
NULL,
NULL,
NULL,
'杭州市测试区家庭路1号',
NULL,
'员工甲与子女关系',
'1',
'2024-01-01 00:00:00',
NULL,
'GRAPH_TEST',
'GRAPH_TEST')
ON DUPLICATE KEY UPDATE
from_key = VALUES(from_key),
to_key = VALUES(to_key),
person_id = VALUES(person_id),
relation_name = VALUES(relation_name),
relation_type = VALUES(relation_type),
relation_cert_no = VALUES(relation_cert_no),
gender = VALUES(gender),
birth_date = VALUES(birth_date),
relation_cert_type = VALUES(relation_cert_type),
mobile_phone1 = VALUES(mobile_phone1),
contact_address = VALUES(contact_address),
annual_income = VALUES(annual_income),
relation_desc = VALUES(relation_desc),
status = VALUES(status),
effective_date = VALUES(effective_date),
remark = VALUES(remark),
data_source = VALUES(data_source),
updated_time = CURRENT_TIMESTAMP;
-- 测试点:工商企业、股东、法人。
INSERT INTO lx_rel_node (
object_key,
node_name,
id_number,
subject_type,
source_type,
detail_ref_type,
detail_ref_key
)
VALUES
(@company_key, '关系图谱测试科技有限公司', @company_credit_code, '企业', 'GRAPH_TEST_工商企业', 'ECI_COMPANY', @company_key),
(@person_holder_key, '关系图谱测试自然人股东', NULL, '个人', 'GRAPH_TEST_自然人股东', 'ECI_PARTNER', @person_holder_key),
(@enterprise_holder_key, '关系图谱测试企业股东有限公司', '91330100GRAPH00002', '企业', 'GRAPH_TEST_企业股东', 'ECI_PARTNER', @enterprise_holder_key),
(@legal_key, '关系图谱测试法定代表人', NULL, '个人', 'GRAPH_TEST_法定代表人', 'ECI_COMPANY_OPERATOR', @legal_key)
ON DUPLICATE KEY UPDATE
node_name = VALUES(node_name),
id_number = VALUES(id_number),
subject_type = VALUES(subject_type),
source_type = VALUES(source_type),
detail_ref_type = VALUES(detail_ref_type),
detail_ref_key = VALUES(detail_ref_key),
updated_time = CURRENT_TIMESTAMP;
-- 测试边:股东持股关系。
INSERT INTO lx_rel_stock_edge (
object_key,
from_key,
to_key,
company_name,
stock_name,
stock_type,
stock_percent,
should_capi,
should_capi_value,
should_capi_unit,
shoud_date,
p_key_no
)
VALUES
('GRAPH_TEST_STOCK_PERSON_001',
CONCAT('rel_node/', @person_holder_key),
CONCAT('rel_node/', @company_key),
'关系图谱测试科技有限公司',
'关系图谱测试自然人股东',
'自然人股东',
'60%',
'600',
'600',
'万元人民币',
'2024-05-01',
@person_holder_key),
('GRAPH_TEST_STOCK_SPOUSE_CANDIDATE_001',
CONCAT('rel_node/', @spouse_holder_key),
CONCAT('rel_node/', @company_key),
'关系图谱测试科技有限公司',
'关系图谱测试员工乙',
'自然人股东',
'15%',
'150',
'150',
'万元人民币',
'2024-05-01',
@spouse_holder_key),
('GRAPH_TEST_STOCK_ENTERPRISE_001',
CONCAT('rel_node/', @enterprise_holder_key),
CONCAT('rel_node/', @company_key),
'关系图谱测试科技有限公司',
'关系图谱测试企业股东有限公司',
'企业股东',
'40%',
'400',
'400',
'万元人民币',
'2024-05-01',
@enterprise_holder_key)
ON DUPLICATE KEY UPDATE
from_key = VALUES(from_key),
to_key = VALUES(to_key),
company_name = VALUES(company_name),
stock_name = VALUES(stock_name),
stock_type = VALUES(stock_type),
stock_percent = VALUES(stock_percent),
should_capi = VALUES(should_capi),
should_capi_value = VALUES(should_capi_value),
should_capi_unit = VALUES(should_capi_unit),
shoud_date = VALUES(shoud_date),
p_key_no = VALUES(p_key_no),
updated_time = CURRENT_TIMESTAMP;
-- 测试边:法定代表人关系。
INSERT INTO lx_rel_represent_edge (
object_key,
from_key,
to_key,
oper_name,
oper_key_no
)
VALUES
('GRAPH_TEST_REPRESENT_001',
CONCAT('rel_node/', @legal_key),
CONCAT('rel_node/', @company_key),
'关系图谱测试法定代表人',
@legal_key)
ON DUPLICATE KEY UPDATE
from_key = VALUES(from_key),
to_key = VALUES(to_key),
oper_name = VALUES(oper_name),
oper_key_no = VALUES(oper_key_no),
updated_time = CURRENT_TIMESTAMP;
SELECT 'lx_rel_node' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_rel_node
WHERE source_type LIKE 'GRAPH_TEST%'
UNION ALL
SELECT 'lx_rel_family_edge' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_rel_family_edge
WHERE data_source = 'GRAPH_TEST'
UNION ALL
SELECT 'lx_rel_stock_edge' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_rel_stock_edge
WHERE object_key LIKE 'GRAPH_TEST_%'
UNION ALL
SELECT 'lx_rel_represent_edge' AS table_name, COUNT(1) AS graph_test_rows
FROM lx_rel_represent_edge
WHERE object_key LIKE 'GRAPH_TEST_%';
SELECT
@staff_a_id AS test_person_id,
@staff_a_key AS test_object_key,
@company_key AS test_company_object_key;

View File

@@ -0,0 +1,490 @@
-- ============================================================
-- 资金流图谱已建表环境补充脚本
-- 说明:
-- 1. 本脚本用于目标库已存在 lx_fund_flow_* 表时补齐新增字段和索引。
-- 2. 不删除、不重建已有图谱表,不清空 ODPS 基座或生产数据。
-- 3. 新环境初始化仍可参考 01_lx_fund_graph_mysql_ddl.sql。
-- 4. 执行前需人工确认当前库为目标库;涉及中文注释时使用 bin/mysql_utf8_exec.sh 执行。
-- ============================================================
SET @current_schema = DATABASE();
-- ============================================================
-- lx_fund_flow_subject_node 补充字段和索引
-- ============================================================
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND COLUMN_NAME = 'cinocsno') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD COLUMN cinocsno VARCHAR(64) NULL COMMENT ''客户内部号'' AFTER name',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND COLUMN_NAME = 'idno_type') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD COLUMN idno_type VARCHAR(64) NULL COMMENT ''主体类型,如个人、企业、代理主体'' AFTER cinocsno',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND COLUMN_NAME = 'staff_id') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD COLUMN staff_id VARCHAR(64) NULL COMMENT ''员工标识'' AFTER idno_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND COLUMN_NAME = 'source_type') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD COLUMN source_type VARCHAR(64) NULL COMMENT ''来源类型'' AFTER staff_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND COLUMN_NAME = 'created_time') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD COLUMN created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'' AFTER source_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND COLUMN_NAME = 'updated_time') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD COLUMN updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER created_time',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND INDEX_NAME = 'idx_lx_fund_flow_subject_idnocfno') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD KEY idx_lx_fund_flow_subject_idnocfno (idnocfno)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND INDEX_NAME = 'idx_lx_fund_flow_subject_name') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD KEY idx_lx_fund_flow_subject_name (name)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_subject_node' AND INDEX_NAME = 'idx_lx_fund_flow_subject_source_type') = 0,
'ALTER TABLE lx_fund_flow_subject_node ADD KEY idx_lx_fund_flow_subject_source_type (source_type)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- lx_fund_flow_account_node 补充字段和索引
-- ============================================================
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND COLUMN_NAME = 'source') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD COLUMN source VARCHAR(128) NULL COMMENT ''来源'' AFTER cinocsno',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND COLUMN_NAME = 'acc_type') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD COLUMN acc_type VARCHAR(64) NULL COMMENT ''账户类型如INTERNAL/EXTERNAL'' AFTER source',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND COLUMN_NAME = 'acc_idno') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD COLUMN acc_idno VARCHAR(64) NULL COMMENT ''开户证件号'' AFTER acc_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND COLUMN_NAME = 'acc_status') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD COLUMN acc_status VARCHAR(64) NULL COMMENT ''账户状态'' AFTER acc_idno',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND COLUMN_NAME = 'acc_date') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD COLUMN acc_date VARCHAR(32) NULL COMMENT ''开户日期'' AFTER acc_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND COLUMN_NAME = 'created_time') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD COLUMN created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'' AFTER acc_date',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND COLUMN_NAME = 'updated_time') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD COLUMN updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER created_time',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND INDEX_NAME = 'idx_lx_fund_flow_account_acc_no') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD KEY idx_lx_fund_flow_account_acc_no (acc_no)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND INDEX_NAME = 'idx_lx_fund_flow_account_acc_name') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD KEY idx_lx_fund_flow_account_acc_name (acc_name)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_account_node' AND INDEX_NAME = 'idx_lx_fund_flow_account_acc_idno') = 0,
'ALTER TABLE lx_fund_flow_account_node ADD KEY idx_lx_fund_flow_account_acc_idno (acc_idno)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- lx_fund_flow_own_account_edge 补充字段和索引
-- ============================================================
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_own_account_edge' AND COLUMN_NAME = 'acc_name') = 0,
'ALTER TABLE lx_fund_flow_own_account_edge ADD COLUMN acc_name VARCHAR(255) NULL COMMENT ''账户名称'' AFTER to_key',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_own_account_edge' AND COLUMN_NAME = 'acc_no') = 0,
'ALTER TABLE lx_fund_flow_own_account_edge ADD COLUMN acc_no VARCHAR(128) NULL COMMENT ''账号'' AFTER acc_name',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_own_account_edge' AND COLUMN_NAME = 'created_time') = 0,
'ALTER TABLE lx_fund_flow_own_account_edge ADD COLUMN created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'' AFTER acc_no',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_own_account_edge' AND COLUMN_NAME = 'updated_time') = 0,
'ALTER TABLE lx_fund_flow_own_account_edge ADD COLUMN updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER created_time',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_own_account_edge' AND INDEX_NAME = 'idx_lx_fund_flow_own_from_key') = 0,
'ALTER TABLE lx_fund_flow_own_account_edge ADD KEY idx_lx_fund_flow_own_from_key (from_key)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_own_account_edge' AND INDEX_NAME = 'idx_lx_fund_flow_own_to_key') = 0,
'ALTER TABLE lx_fund_flow_own_account_edge ADD KEY idx_lx_fund_flow_own_to_key (to_key)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_own_account_edge' AND INDEX_NAME = 'idx_lx_fund_flow_own_acc_no') = 0,
'ALTER TABLE lx_fund_flow_own_account_edge ADD KEY idx_lx_fund_flow_own_acc_no (acc_no)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- lx_fund_flow_detail_edge 补充字段和索引
-- ============================================================
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND COLUMN_NAME = 'family_relation_type') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD COLUMN family_relation_type VARCHAR(64) NULL COMMENT ''家庭关系类型,如配偶、父母、子女'' AFTER to_key',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND COLUMN_NAME = 'source_table') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD COLUMN source_table VARCHAR(32) NULL COMMENT ''流水来源基座可为空增量可为CCDI_BANK_STATEMENT/FIRST/LEVEL1'' AFTER family_relation_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND COLUMN_NAME = 'penetrate_level') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD COLUMN penetrate_level INT NOT NULL DEFAULT 0 COMMENT ''穿透层级一期为0'' AFTER source_table',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND COLUMN_NAME = 'bank_statement_id') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD COLUMN bank_statement_id BIGINT NULL COMMENT ''增量来源ccdi_bank_statement主键'' AFTER penetrate_level',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND COLUMN_NAME = 'bank_trx_number') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD COLUMN bank_trx_number VARCHAR(128) NULL COMMENT ''银行流水号/交易流水号,如来源存在则记录'' AFTER bank_statement_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND COLUMN_NAME = 'created_time') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD COLUMN created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'' AFTER bank_trx_number',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND COLUMN_NAME = 'updated_time') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD COLUMN updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER created_time',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND INDEX_NAME = 'idx_lx_fund_flow_detail_from_to') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD KEY idx_lx_fund_flow_detail_from_to (from_key, to_key)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND INDEX_NAME = 'idx_lx_fund_flow_detail_from_date') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD KEY idx_lx_fund_flow_detail_from_date (from_key, trx_date)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND INDEX_NAME = 'idx_lx_fund_flow_detail_to_date') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD KEY idx_lx_fund_flow_detail_to_date (to_key, trx_date)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND INDEX_NAME = 'idx_lx_fund_flow_detail_trx_date') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD KEY idx_lx_fund_flow_detail_trx_date (trx_date)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND INDEX_NAME = 'idx_lx_fund_flow_detail_statement') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD KEY idx_lx_fund_flow_detail_statement (bank_statement_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_detail_edge' AND INDEX_NAME = 'idx_lx_fund_flow_detail_family') = 0,
'ALTER TABLE lx_fund_flow_detail_edge ADD KEY idx_lx_fund_flow_detail_family (family_relation_type)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- lx_fund_flow_manual_edge 如不存在则补建;已存在则补索引
-- ============================================================
CREATE TABLE IF NOT EXISTS lx_fund_flow_manual_edge (
object_key VARCHAR(64) NOT NULL COMMENT '手工资金流向汇总边唯一标识',
from_object_key VARCHAR(64) NOT NULL COMMENT '起点主体object_key身份证MD5或手工主体MD5',
to_object_key VARCHAR(64) NOT NULL COMMENT '终点主体object_key身份证MD5或手工主体MD5',
from_name VARCHAR(255) NULL COMMENT '起点主体名称冗余',
to_name VARCHAR(255) NULL COMMENT '终点主体名称冗余',
amount DECIMAL(19, 2) NULL COMMENT '手工录入汇总金额',
transaction_count INT NOT NULL DEFAULT 1 COMMENT '手工录入笔数',
direction VARCHAR(8) NOT NULL COMMENT '资金方向1支出2收入',
relation_desc VARCHAR(255) NULL COMMENT '资金流向关系说明',
source_desc VARCHAR(500) NULL COMMENT '手工边来源说明',
remark VARCHAR(1000) NULL COMMENT '分析备注',
source_type VARCHAR(64) NOT NULL DEFAULT 'MANUAL' COMMENT '来源类型固定为MANUAL',
created_by VARCHAR(64) NULL COMMENT '创建人',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_by VARCHAR(64) NULL COMMENT '更新人',
updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (object_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资金流图谱手工资金流向汇总边表';
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_manual_edge' AND INDEX_NAME = 'idx_lx_fund_flow_manual_from') = 0,
'ALTER TABLE lx_fund_flow_manual_edge ADD KEY idx_lx_fund_flow_manual_from (from_object_key)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_manual_edge' AND INDEX_NAME = 'idx_lx_fund_flow_manual_to') = 0,
'ALTER TABLE lx_fund_flow_manual_edge ADD KEY idx_lx_fund_flow_manual_to (to_object_key)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_manual_edge' AND INDEX_NAME = 'idx_lx_fund_flow_manual_pair_direction') = 0,
'ALTER TABLE lx_fund_flow_manual_edge ADD KEY idx_lx_fund_flow_manual_pair_direction (from_object_key, to_object_key, direction)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl_sql = IF(
(SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema AND TABLE_NAME = 'lx_fund_flow_manual_edge' AND INDEX_NAME = 'idx_lx_fund_flow_manual_source_type') = 0,
'ALTER TABLE lx_fund_flow_manual_edge ADD KEY idx_lx_fund_flow_manual_source_type (source_type)',
'SELECT 1'
);
PREPARE stmt FROM @ddl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- 核对结果
-- ============================================================
SELECT TABLE_NAME, TABLE_COLLATION
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = @current_schema
AND TABLE_NAME IN (
'lx_fund_flow_subject_node',
'lx_fund_flow_account_node',
'lx_fund_flow_own_account_edge',
'lx_fund_flow_detail_edge',
'lx_fund_flow_manual_edge'
)
ORDER BY TABLE_NAME;