diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java new file mode 100644 index 00000000..e41b75d2 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java @@ -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 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 page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize()); + Page 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()); + } + } +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiRelationGraphController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiRelationGraphController.java new file mode 100644 index 00000000..64711804 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiRelationGraphController.java @@ -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 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); + } +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphEdgeDetailQueryDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphEdgeDetailQueryDTO.java new file mode 100644 index 00000000..1710b152 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphEdgeDetailQueryDTO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphManualEdgeSaveDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphManualEdgeSaveDTO.java new file mode 100644 index 00000000..33f79a9d --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphManualEdgeSaveDTO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphQueryDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphQueryDTO.java new file mode 100644 index 00000000..521f7950 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraphQueryDTO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraphQueryDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraphQueryDTO.java new file mode 100644 index 00000000..5cff88e0 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraphQueryDTO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraphSuspectedEnterpriseQueryDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraphSuspectedEnterpriseQueryDTO.java new file mode 100644 index 00000000..1fb89b56 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraphSuspectedEnterpriseQueryDTO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphEdgeVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphEdgeVO.java new file mode 100644 index 00000000..85067947 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphEdgeVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphNodeVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphNodeVO.java new file mode 100644 index 00000000..8a890b95 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphNodeVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphStatementVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphStatementVO.java new file mode 100644 index 00000000..2c6d3818 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphStatementVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphVO.java new file mode 100644 index 00000000..3dc1bf3f --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraphVO.java @@ -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 nodes = new ArrayList<>(); + + private List edges = new ArrayList<>(); + + private BigDecimal totalAmount = BigDecimal.ZERO; + + private Long transactionCount = 0L; + + private Integer maxDepth = 1; + + private Boolean traceReserved = true; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphEdgeVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphEdgeVO.java new file mode 100644 index 00000000..7ab548f5 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphEdgeVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphNodeVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphNodeVO.java new file mode 100644 index 00000000..35b4971c --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphNodeVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphSuspectedEnterpriseItemVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphSuspectedEnterpriseItemVO.java new file mode 100644 index 00000000..732bc5f0 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphSuspectedEnterpriseItemVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphSuspectedEnterpriseVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphSuspectedEnterpriseVO.java new file mode 100644 index 00000000..55e698e9 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphSuspectedEnterpriseVO.java @@ -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 rows = new ArrayList<>(); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphVO.java new file mode 100644 index 00000000..e68e755a --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraphVO.java @@ -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 nodes = new ArrayList<>(); + + private List edges = new ArrayList<>(); + + private Long edgeCount = 0L; + + private Integer maxDepth = 1; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFundGraphMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFundGraphMapper.java new file mode 100644 index 00000000..6de311f2 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFundGraphMapper.java @@ -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 selectFundGraphSubjects(@Param("query") CcdiFundGraphQueryDTO query); + + List selectFundGraphEdges(@Param("query") CcdiFundGraphQueryDTO query); + + List 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 selectFundGraphEdgeDetails( + Page page, + @Param("query") CcdiFundGraphEdgeDetailQueryDTO query + ); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiRelationGraphMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiRelationGraphMapper.java new file mode 100644 index 00000000..9ed42846 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiRelationGraphMapper.java @@ -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 selectRelationGraphSubjects(@Param("query") CcdiRelationGraphQueryDTO query); + + List selectRelationGraphNodesByKeys(@Param("objectKeys") List objectKeys); + + List selectRelationGraphEdges(@Param("query") CcdiRelationGraphQueryDTO query); + + int countSuspectedEnterpriseKeyNos(@Param("personName") String personName); + + List selectSuspectedEnterprises(@Param("personName") String personName, + @Param("limit") Integer limit); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFundGraphService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFundGraphService.java new file mode 100644 index 00000000..5b8a1a67 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFundGraphService.java @@ -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 searchSubjects(CcdiFundGraphQueryDTO queryDTO); + + CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO); + + Page getEdgeDetails( + Page page, + CcdiFundGraphEdgeDetailQueryDTO queryDTO + ); + + CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiRelationGraphService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiRelationGraphService.java new file mode 100644 index 00000000..6adec2ce --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiRelationGraphService.java @@ -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 searchSubjects(CcdiRelationGraphQueryDTO queryDTO); + + CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO); + + CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFundGraphServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFundGraphServiceImpl.java new file mode 100644 index 00000000..e93c643b --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFundGraphServiceImpl.java @@ -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 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 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 edges = new ArrayList<>(); + List realEdges = fundGraphMapper.selectFundGraphEdges(query); + if (realEdges != null) { + edges.addAll(realEdges); + } + List 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 getEdgeDetails( + Page 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 subjects = fundGraphMapper.selectFundGraphSubjects(query); + if (subjects == null || subjects.isEmpty()) { + return null; + } + return subjects.get(0); + } + + private List buildNodes(CcdiFundGraphNodeVO centerNode, List edges) { + Map nodeMap = new LinkedHashMap<>(); + Map 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 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 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 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 sortAndLimitEdges(List edges, Integer limit) { + if (edges == null || edges.isEmpty()) { + return Collections.emptyList(); + } + List 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; + } +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java index 6c46e22a..2f497870 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java @@ -23,6 +23,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,6 +54,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService { private ICcdiProjectService projectService; @Resource + @Lazy private ICcdiBankTagService bankTagService; @Override diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java index 2747e5e1..a06c0c1c 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java @@ -57,6 +57,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -90,6 +91,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi private CcdiProjectOverviewReportPdfExporter reportPdfExporter; @Resource + @Lazy private ICcdiModelParamService modelParamService; @Override diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiRelationGraphServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiRelationGraphServiceImpl.java new file mode 100644 index 00000000..83a2523f --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiRelationGraphServiceImpl.java @@ -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 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 edges = relationGraphMapper.selectRelationGraphEdges(query); + if (edges == null) { + edges = Collections.emptyList(); + } + + List 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 rows = + relationGraphMapper.selectSuspectedEnterprises(query.getPersonName(), MAX_SUSPECTED_LIMIT); + List 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 subjects = relationGraphMapper.selectRelationGraphSubjects(query); + if (subjects == null || subjects.isEmpty()) { + return null; + } + return subjects.get(0); + } + + private List buildNodes(CcdiRelationGraphNodeVO centerNode, List edges) { + Set 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 rawNodes = relationGraphMapper.selectRelationGraphNodesByKeys(new ArrayList<>(objectKeys)); + Map 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(); + } +} diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFundGraphMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFundGraphMapper.xml new file mode 100644 index 00000000..aa76e935 --- /dev/null +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFundGraphMapper.xml @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AND d.trx_date = ]]> (#{query.transactionStartTime} COLLATE utf8mb4_general_ci) + + + AND d.trx_date + (CASE + WHEN LENGTH(TRIM(#{query.transactionEndTime})) = 10 + THEN CONCAT(TRIM(#{query.transactionEndTime}), ' 23:59:59') + ELSE TRIM(#{query.transactionEndTime}) + END COLLATE utf8mb4_general_ci) + + + AND d.amount = ]]> #{query.amountMin} + + + AND d.amount #{query.amountMax} + + + AND d.flag = (#{query.direction} COLLATE utf8mb4_general_ci) + + + + + 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 + + AND ( + from_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci) + OR to_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci) + ) + + + + + + + + + + + + + + + + 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 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} + ) + + diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiRelationGraphMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiRelationGraphMapper.xml new file mode 100644 index 00000000..7b4ba23a --- /dev/null +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiRelationGraphMapper.xml @@ -0,0 +1,330 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + diff --git a/docs/plans/backend/2026-05-28-fund-graph-backend-implementation.md b/docs/plans/backend/2026-05-28-fund-graph-backend-implementation.md new file mode 100644 index 00000000..b4eb7537 --- /dev/null +++ b/docs/plans/backend/2026-05-28-fund-graph-backend-implementation.md @@ -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 已把人员、企业、账号、名称代理做成统一节点键,后续多层追溯可沿用同一套节点键。 diff --git a/docs/plans/frontend/2026-05-28-fund-graph-frontend-implementation.md b/docs/plans/frontend/2026-05-28-fund-graph-frontend-implementation.md new file mode 100644 index 00000000..8c5c628f --- /dev/null +++ b/docs/plans/frontend/2026-05-28-fund-graph-frontend-implementation.md @@ -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`。 +- 未来可在节点点击事件中调用后端追溯接口,把新增节点和边合并进现有图谱。 +- 组件已按一层查询和边明细查询拆分,后续追溯不会影响“点边看流水”的核心链路。 diff --git a/docs/plans/fullstack/2026-05-28-graph-development-decisions.md b/docs/plans/fullstack/2026-05-28-graph-development-decisions.md new file mode 100644 index 00000000..34998de0 --- /dev/null +++ b/docs/plans/fullstack/2026-05-28-graph-development-decisions.md @@ -0,0 +1,609 @@ +# 图谱开发决策记录 + +记录当前已确认的资金流图谱和关系图谱开发口径,作为后续开发、验收和跨对话延续的依据。 + +## 1. 页面嵌入位置 + +- 图谱功能先嵌入项目详情页的“专项排查”页签。 +- 现有前端入口为 `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`。 +- 页面在项目详情内承载,但资金流图谱本身不按 `project_id` 过滤。 +- 查询入口以全局身份证号 `cret_no` 或员工姓名为准。 + +## 2. 图谱表结构原则 + +- 建表逻辑尽量保持图谱平台已验证过的 SQL 逻辑。 +- 不重新设计统一点表/边表。 +- 表名保留图谱平台 SQL 中的五张结果表: + - `lx_fund_flow_subject_node` + - `lx_fund_flow_account_node` + - `lx_fund_flow_own_account_edge` + - `lx_fund_flow_detail_edge` + - `lx_fund_flow_sum_edge` +- 不在五张图谱表中增加 `project_id` 作为查询过滤口径。 +- 一条流水可能存在于多个项目中,资金流图谱按全局资金关系构建,避免项目维度导致重复建点或重复算边。 + +## 3. 五张表职责 + +| 表名 | 作用 | +| --- | --- | +| `lx_fund_flow_subject_node` | 主体点,表示人员、企业、名称代理主体 | +| `lx_fund_flow_account_node` | 账户点,表示具体账号或名称代理账户 | +| `lx_fund_flow_own_account_edge` | 主体到账户的持有关系 | +| `lx_fund_flow_detail_edge` | 账户层逐笔资金交易边 | +| `lx_fund_flow_sum_edge` | 主体层资金汇总边,前端默认展示 | + +关键点: + +- 一个人可能有多个账号,所以必须保留主体点、账户点、持有边三层结构。 +- 前端默认展示主体层汇总边,不默认展示全部账户层明细边,避免节点过多。 +- 点击汇总边后,再查询账户层逐笔流水。 + +## 4. 构建逻辑 + +构建逻辑以 `tupu/资金流图谱代码/lanxi_liushui_no_relation_simplified.sql` 为主。 + +重要口径: + +- 项目内 SQL 尽量和图谱平台原 SQL 保持一致。 +- 先导入一部分“所有员工流水明细”作为图谱基座。 +- 这部分基座数据视为已验证、绝对正确,不应被后续构建流程随意清空或覆盖。 +- 后续从 `ccdi_bank_statement` 拉取新增流水时,需要先和图谱基座做一致性判断。 +- 如果 `ccdi_bank_statement` 中的流水已经能在图谱基座中匹配到一致流水,则不同步进图谱,避免重复计入。 +- 如果没有匹配到一致流水,则按原图谱 SQL 逻辑增量插入图谱。 + +保留的核心口径: + +- 本方证件号 `cret_no` 必须存在。 +- 对手方名称必须存在,空值、空串、`0` 过滤。 +- 金额必须有效,支出和收入统一成 `amount` 与 `flag`。 +- 支出 `flag = 1`,收入 `flag = 2`。 +- 明细去重,避免重复流水导致金额和笔数翻倍。 +- 同名归并只作用于主体层,不改变账户节点和账户明细边。 +- 无账号但有名称的对手方,按原 SQL 逻辑生成名称代理账户和名称代理主体。 + +一期先不展开追溯能力。需要为后续追溯预留字段和逻辑口子: + +- `source_table` +- `penetrate_level` +- 后续可扩展 `FIRST`、`LEVEL1` 等来源。 + +## 4.1 基座与增量同步口径 + +图谱表不是“清空重建”的临时结果表,而是承载已验证图谱基座和后续增量的正式结果表。 + +基座数据: + +- 来源为先导入的所有员工流水明细。 +- 按原图谱平台 SQL 生成五张 `lx_*` 表。 +- 基座数据作为可信结果保留。 + +增量数据: + +- 来源为后续 `ccdi_bank_statement`。 +- 拉取后先按原 SQL 的流水标准化和去重口径生成候选流水。 +- 候选流水和既有 `lx_fund_flow_detail_edge` 做一致性比对。 +- 已存在一致流水时跳过,不插入图谱。 +- 不存在一致流水时,再增量生成账户点、主体点、持有边、明细边和汇总边。 + +一致性比对建议使用稳定业务特征,而不是 `project_id`: + +- 本方账号 +- 本方户名 +- 对手方账号 +- 对手方户名 +- 交易日期 +- 金额 +- 收支方向 `flag` +- 摘要 `user_memo` +- 银行流水号或交易流水号,如果有 + +增量插入要求: + +- 点表按 `object_key` 去重插入。 +- 持有边按 `object_key` 去重插入。 +- 明细边先判重,未存在才插入。 +- 汇总边需要按主体对和方向重新聚合或局部 upsert,不能简单追加导致金额翻倍。 + +## 4.2 ODPS 基座同步到 MySQL + +当前真实部署口径: + +- 原图谱 SQL 已在 ODPS 中有一份结果。 +- ODPS 结果只涉及行内流水。 +- ODPS 已经产出五张图谱结果表。 +- 可以先将 ODPS 中五张结果表一次性同步到纪检 MySQL。 +- MySQL 同步建表脚本记录在 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`。 +- 生产数据库表结构变更由人工单独执行,不跟随测试环境或应用发布自动更新。 +- 项目内保留 SQL 文件,用于本地验证、评审和生产手动执行参考。 + +同步后的 MySQL 五张表继续使用原图谱表名: + +- `lx_fund_flow_subject_node` +- `lx_fund_flow_account_node` +- `lx_fund_flow_own_account_edge` +- `lx_fund_flow_detail_edge` +- `lx_fund_flow_sum_edge` + +同步要求: + +- ODPS 到 MySQL 首次同步只做基座装载。 +- 基座装载完成后,后续不再通过清空五张表重建处理。 +- ODPS 字段和 MySQL 字段同名的按显式字段列表导入。 +- MySQL 侧新增字段如 `family_relation_type`、`summary_object_key`、`source_table`、`penetrate_level`、`bank_statement_id`、`bank_trx_number` 可为空。 +- `lx_fund_flow_sum_edge.detail_ids` 在 MySQL 中使用 `LONGTEXT` 接收 ODPS ARRAY 同步后的 JSON 或字符串表示,前后端查询不强依赖该字段。 + +## 4.3 MySQL 后续增量方式 + +后续新增数据都在纪检 MySQL 内处理: + +- 来源表为 `ccdi_bank_statement`。 +- 增量逻辑从 `ccdi_bank_statement` 抽取候选流水。 +- 候选流水按原图谱 SQL 口径标准化、过滤、生成 object_key。 +- 先和既有 `lx_fund_flow_detail_edge` 做一致性判重。 +- 已存在一致流水时不插入图谱。 +- 不存在一致流水时,才增量插入点、账户、持有边、明细边。 +- 汇总边 `lx_fund_flow_sum_edge` 按主体对和方向重新聚合或局部 upsert。 + +调度建议: + +- 一期建议做每日定时调度,不建议一开始做实时。 +- 推荐使用 RuoYi/Quartz 定时任务,每天凌晨或低峰期执行。 +- 同时保留后台手动触发能力,便于首次补跑、排查和修复。 +- 实时同步不是不能做,但没有必要优先做;实时会增加事务、锁、重复判断和汇总边更新复杂度。 + +推荐执行节奏: + +1. ODPS 五张结果表一次性同步到 MySQL。 +2. MySQL 跑一次家庭关系补充和 `summary_object_key` 回填。 +3. 每日 Quartz 调度从 `ccdi_bank_statement` 抽取新增候选流水。 +4. 候选流水与 `lx_fund_flow_detail_edge` 判重。 +5. 未命中重复的流水增量入图。 +6. 更新对应主体层汇总边。 +7. 前端始终只基于 MySQL 五张 `lx_*` 图谱表查询展示。 + +## 4.4 数据库变更执行边界 + +数据库表结构改动属于生产库手工变更,不纳入测试环境自动更新。 + +后续开发分工: + +- SQL 文件由代码库保留,作为生产手工执行依据和本地验证依据。 +- 生产执行由人工确认后单独处理。 +- 后端开发默认这些表在目标库中已经存在。 +- 前端开发不感知数据库变更,只调用后端接口。 +- 测试环境如没有这五张表,需要手动执行 SQL 后再联调。 +- 应用发布包不自动执行这些 DDL。 + +## 5. 家庭关系 + +家庭关系是本次项目内新增能力,参考 `tupu/资金流图谱代码/资金流图谱_家庭关系补充.sql`。 + +处理原则: + +- 不改变资金方向。 +- 不改变主体归并逻辑。 +- 不改变账户层明细边生成逻辑。 +- 只在资金边上增加家庭关系标注。 + +匹配规则: + +- 交易任意一侧可映射到员工主体。 +- 员工主体必须有身份证号。 +- 对手方姓名命中 `ccdi_staff_fmy_relation.relation_name`。 +- 同一员工和同一关系人姓名只有一个 `relation_type` 时才标注。 +- 多个关系类型冲突时不打标,避免误判。 + +建议补充字段: + +- `lx_fund_flow_detail_edge.family_relation_type` +- `lx_fund_flow_sum_edge.family_relation_type` + +## 6. 查询逻辑 + +搜索主体: + +```sql +select * +from lx_fund_flow_subject_node +where idnocfno = #{keyword} + or name like concat('%', #{keyword}, '%'); +``` + +查询主体层资金图: + +```sql +select * +from lx_fund_flow_sum_edge +where from_key = concat('idno_node/', #{objectKey}) + or to_key = concat('idno_node/', #{objectKey}) +order by amount desc, total_trans_cnt desc +limit #{limit}; +``` + +点击汇总边查询逐笔流水: + +```sql +select * +from lx_fund_flow_detail_edge +where summary_object_key = #{sumObjectKey} +order by trx_date desc +limit #{offset}, #{pageSize}; +``` + +说明: + +- 原图谱平台 `detail_ids` 可以保留。 +- MySQL 分页查询建议增加 `summary_object_key`,用于从汇总边直接查明细边。 +- `summary_object_key` 是查询优化字段,不改变原图谱平台点边模型。 + +## 7. 前后端开发边界 + +后端负责: + +- 从五张 `lx_*` 图谱结果表读取数据。 +- 按身份证号或姓名定位主体。 +- 返回主体层图谱节点和汇总边。 +- 支持点击资金汇总边分页查询逐笔流水。 +- 透出家庭关系字段。 +- 空表或脏数据时返回空结果,不让前端报错。 + +前端负责: + +- 在“专项排查”页签中呈现图谱展示区域。 +- 支持身份证号或员工姓名搜索。 +- 支持“资金图谱 / 关系图谱”页签。 +- 一期资金图谱做实,关系图谱可先保留入口。 +- 默认展示主体层资金汇总图。 +- 点击边展示逐笔流水明细。 +- 展示图谱明细边中已写入的家庭关系标签,如配偶、父母、子女。 + +## 8. 基座保护与异常数据处理 + +图谱表承载已验证基座,不按“可随意清空重建”设计。人工可以维护或清理异常数据,但默认应保护已有基座。 + +处理要求: + +- 后续增量同步不得清空五张 `lx_*` 表。 +- 增量同步前必须先判断候选流水是否已存在于 `lx_fund_flow_detail_edge`。 +- 已存在一致流水时跳过,避免重复金额和重复笔数。 +- 人工清理异常边后,后端查询需要能容忍局部缺失数据。 +- 边表有、点表缺失时,后端过滤无法匹配节点的边,不让前端报错。 +- 明细边为空时,点击汇总边提示暂无逐笔流水。 +- 如果人工确实清理了部分图谱数据,后续增量插入仍需按 `object_key` 和流水一致性规则防重。 + +## 9. UI 风格 + +固定设计口径: + +- 浅色系统风格。 +- 正式后台质感。 +- 与当前纪检系统色调保持一致,蓝、白、灰为主。 +- 朝图谱平台式交互靠齐。 +- 不做黑色大屏。 +- 不做网感、霓虹、炫光科技风。 +- 图谱画布使用浅灰白背景,边界清楚。 +- 搜索区放在顶部,支持身份证号和姓名。 +- 页签为“资金图谱”和“关系图谱”。 +- 明细使用右侧抽屉或下方面板展示,优先保证字段清楚和分页性能。 + +## 10. 后续开发顺序 + +建议顺序: + +1. 先落项目内 SQL:DDL、构建 SQL、家庭关系补充 SQL、索引 SQL。 +2. 先支持已导入员工流水明细作为图谱基座。 +3. 增加 `ccdi_bank_statement` 到图谱表的增量同步逻辑。 +4. 增量同步必须先和既有 `lx_fund_flow_detail_edge` 做一致性判重,已存在则不同步。 +5. 后端接口改为读取五张 `lx_*` 图谱表。 +6. 前端在“专项排查”页签接入图谱展示区域。 +7. 完成资金图谱搜索、展示、点击边查明细。 +8. 增加家庭关系标签展示。 +9. 验证基座保护、增量防重、一个人多个账号、家庭关系命中等场景。 + +## 11. 当前代码进度与偏差 + +截至 2026-05-28,项目内已经做过一版一期资金流图谱代码,但这版实现口径与当前最终方案不完全一致,后续需要重构而不是直接当最终版。 + +已完成过的代码: + +- 后端新增 `CcdiFundGraphController`,接口路径为 `/ccdi/project/fund-graph/graph` 和 `/ccdi/project/fund-graph/edge-detail`。 +- 后端新增 DTO/VO:`CcdiFundGraphQueryDTO`、`CcdiFundGraphEdgeDetailQueryDTO`、`CcdiFundGraphVO`、`CcdiFundGraphNodeVO`、`CcdiFundGraphEdgeVO`、`CcdiFundGraphStatementVO`。 +- 后端新增 Mapper 和 Service:`CcdiFundGraphMapper`、`ICcdiFundGraphService`、`CcdiFundGraphServiceImpl`、`CcdiFundGraphMapper.xml`。 +- 前端新增接口文件 `ruoyi-ui/src/api/ccdi/fundGraph.js`。 +- 前端新增组件 `ProjectAnalysisFundFlowTab.vue`。 +- 前端已在 `ProjectAnalysisDialog.vue` 中接入资金流图谱页签。 + +旧版已验证情况: + +- `mvn -pl ccdi-project -am compile -DskipTests` 通过。 +- `npm run build:prod` 通过。 +- 真实库只读校验过项目 33 和姓名样例,能查出资金边,点击边能查逐笔流水。 +- 曾修正 MySQL 8 保留词别名 `rows` 和字符集排序规则不一致问题。 + +当前偏差: + +- 旧版接口是实时从 `ccdi_bank_statement` 聚合资金图谱,不读取五张 `lx_*` 图谱结果表。 +- 旧版查询带项目上下文,当前最终口径是不按 `project_id` 过滤,以全局 `cret_no` 或姓名为入口。 +- 旧版前端接在项目分析弹窗 `ProjectAnalysisDialog`,当前最终入口应放在“专项排查”页签。 +- 旧版没有按图谱平台五表基座和增量同步口径处理。 +- 旧版没有家庭关系标注。 + +后续处理原则: + +- 可复用旧版的图谱展示、点击边查明细、分页表格等前端交互经验。 +- 可复用旧版 DTO/VO 中适合前端展示的字段,但字段来源需要改为五张 `lx_*` 表。 +- 后端 SQL 必须从实时聚合 `ccdi_bank_statement` 改为读取 `lx_fund_flow_subject_node`、`lx_fund_flow_account_node`、`lx_fund_flow_own_account_edge`、`lx_fund_flow_detail_edge`、`lx_fund_flow_sum_edge`。 +- 前端入口需要从项目分析弹窗迁移或重做到“专项排查”页签。 +- 旧版文件在重构时应谨慎处理,避免影响当前项目分析弹窗已有功能。 + +## 12. 页面查询与汇总表最新决策 + +最新决策: + +- 纪检平台资金流图谱页面不强依赖 `lx_fund_flow_sum_edge`。 +- 页面查询以 `lx_fund_flow_detail_edge` 为事实表,由后端按当前查询条件实时聚合。 +- 前端不做金额和笔数聚合,只负责渲染后端返回的节点、边和明细。 +- `lx_fund_flow_sum_edge` 如生产侧不需要兼容图谱平台页面,可以不作为纪检页面必需表。 +- 如果 ODPS 已有 `lx_fund_flow_sum_edge`,可以选择不同步到 MySQL,或同步后仅作为参考缓存,不作为纪检页面查询依据。 + +原因: + +- 用户每次查询通常以一个人为中心,一跳图谱范围可控。 +- 用户需要按 `trx_date` 任意筛选时间范围。 +- 全量 `lx_fund_flow_sum_edge` 不能准确表达任意时间段内的金额和笔数。 +- 每天新增明细后维护汇总表会增加复杂度。 +- 后端从明细边实时聚合能保证筛选结果准确,且比前端聚合更可靠。 + +后端聚合口径: + +1. 用身份证号、姓名或 `object_key` 定位主体点。 +2. 查询该主体名下账户。 +3. 从 `lx_fund_flow_detail_edge` 查询这些账户相关流水。 +4. 按 `trx_date`、金额、方向、家庭关系等筛选条件过滤。 +5. 后端将账户层明细边聚合为主体层资金边。 +6. 返回前端用于图谱展示。 + +时间筛选字段: + +- 所有时间筛选基于 `lx_fund_flow_detail_edge.trx_date`。 +- 不用 `lx_fund_flow_sum_edge.first_trx_date` 或 `lastest_trx_date` 判断筛选结果。 + +## 13. 节点穿透最新决策 + +节点穿透以 `lx_fund_flow_subject_node.object_key` 为唯一标识,不按姓名穿透。 + +口径: + +- 实名主体按原 SQL 逻辑生成 `object_key`,即 `md5(trim(idnocfno))`。 +- 用户点击节点后,可选择“以此节点为中心查询”或“展开此节点”。 +- 默认不自动穿透,避免图谱过长和误展开。 +- 后端按被选节点的 `object_key` 查询其账户和流水。 +- 节点是否可穿透由后端返回 `canExpand` 控制。 + +允许穿透: + +- 有明确身份证号或证件号的实名主体。 +- 有明确账户归属、能通过 `lx_fund_flow_own_account_edge` 找到账户的主体。 + +默认不穿透: + +- 只有名称、没有证件号、没有明确账户归属的名称代理主体。 +- 无法通过 `object_key` 准确定位账户集合的节点。 + +交互建议: + +- 一期做“设为中心查询”,即点击节点后重新以该节点为中心画一跳图。 +- 后续再做“在当前图上追加展开”,避免一期图谱状态管理过复杂。 + +## 14. 最终减法版决策 + +本节覆盖前文早期关于五张表、`lx_fund_flow_sum_edge`、关系图谱页签的旧设想。后续开发以本节为准。 + +本节为 2026-05-28 资金流图谱减法版决策,重点是不依赖 `lx_fund_flow_sum_edge`。关于关系图谱页签,后续已在第 16 节更新为“保留关系图谱能力”,以第 16 节为准。 + +必要表: + +- `lx_fund_flow_subject_node` +- `lx_fund_flow_account_node` +- `lx_fund_flow_own_account_edge` +- `lx_fund_flow_detail_edge` + +不依赖: + +- `lx_fund_flow_sum_edge` + +页面查询: + +- 输入身份证号、姓名或点击节点 `object_key`。 +- 后端定位主体点。 +- 后端查询主体持有账户。 +- 后端从 `lx_fund_flow_detail_edge` 按账户、`trx_date`、金额等条件实时聚合资金边。 +- 前端只渲染后端返回的资金节点、资金边和逐笔明细。 + +家庭关系: + +- 只作为资金流图谱中的标签展示。 +- 家庭关系识别在图谱构建或数据加工阶段完成,并写入 `lx_fund_flow_detail_edge.family_relation_type`;后端查询接口只读取并返回该字段,不实时匹配家庭表。 +- 如果生产构建需要按对手方户名匹配 `ccdi_staff_fmy_relation.relation_name`,应在构建 SQL 中完成,并控制同名误判风险。 +- 有明确 `relation_cert_no` 的家庭关系人按实名主体处理,`object_key = md5(trim(relation_cert_no))`。 +- 用户点击该节点时,可按该节点 `object_key` 设为中心继续查询。 + +测试数据: + +- 测试 DDL 为 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`。 +- 测试数据脚本为 `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`。 +- 测试数据只写四张必要表。 +- 测试数据来源于 dev 库 `ccdi_bank_statement` 和 `ccdi_staff_fmy_relation`。 +- 原始流水表不被修改。 + +## 15. 前后端与页面交互设计 + +默认查询: + +- 默认查询全部流水,不默认带交易日期过滤。 +- 用户选择交易日期后,后端才按 `lx_fund_flow_detail_edge.trx_date` 过滤。 +- 时间过滤不查汇总表,直接从明细边实时聚合。 + +后端接口建议: + +```text +GET /ccdi/project/fund-graph/search +GET /ccdi/project/fund-graph/graph +GET /ccdi/project/fund-graph/edge-detail +POST /ccdi/project/fund-graph/manual-edge + +GET /ccdi/project/relation-graph/search +GET /ccdi/project/relation-graph/graph +GET /ccdi/project/relation-graph/suspected-enterprises +``` + +接口职责: + +- `search`:按身份证号或姓名查主体点,返回候选主体列表。 +- `graph`:按主体 `object_key` 查询一跳资金图,默认全部流水,可选日期、金额、方向过滤。 +- `edge-detail`:点击资金边后,分页查询该边下的逐笔流水。 + +`graph` 入参: + +```text +objectKey 必填,主体 object_key +startDate 可选,交易开始日期 +endDate 可选,交易结束日期 +amountMin 可选,最小金额 +amountMax 可选,最大金额 +direction 可选,1支出、2收入 +limit 可选,默认20 +``` + +`graph` 返回: + +```text +centerNode 当前中心主体 +nodes 图谱节点 +edges 聚合后的资金边 +summary 当前查询范围的总金额、总笔数、家庭关系边数量 +``` + +节点字段: + +```text +objectKey +nodeKey idno_node/{object_key} +name +idNo +nodeType PERSON / PROXY +canExpand +relationType 如果是家庭关系节点,返回配偶/父亲/母亲等 +``` + +边字段: + +```text +edgeKey +fromKey +toKey +fromName +toName +direction 1支出、2收入 +amount +transactionCount +firstTrxDate +lastTrxDate +familyRelationType +``` + +`edge-detail` 入参: + +```text +fromObjectKey +toObjectKey +direction +startDate +endDate +pageNum +pageSize +``` + +页面交互: + +- 页面位置:项目详情“专项排查”页签。 +- 页面标题:图谱展示。 +- 展示“资金流图谱”和“关系图谱”两个页签。 +- 搜索区只保留必要控件:身份证号/姓名、交易日期、查询、重置。 +- 默认空态提示:请输入身份证号或姓名查询资金流图谱。 +- 查询后画一跳图,中心节点为当前人员。 +- 边上只显示金额和笔数,家庭关系用标签显示。 +- 点击资金边,右侧抽屉展示逐笔流水。 +- 点击节点,提供“设为中心查询”;默认不自动穿透。 +- 只有 `canExpand = true` 的节点展示“设为中心查询”。 + +性能口径: + +- 前端不聚合金额和笔数。 +- 后端只围绕一个主体的账户集合查明细边。 +- 默认全部流水也只查当前主体相关边,不扫全表。 +- 必须使用 `lx_fund_flow_own_account_edge.from_key`、`lx_fund_flow_detail_edge.from_key/trx_date`、`lx_fund_flow_detail_edge.to_key/trx_date` 索引。 +- 后端 SQL 参数比较需要显式 `COLLATE utf8mb4_general_ci`,避免当前库连接排序规则和表排序规则不一致。 + +当前 dev 测试数据: + +```text +测试身份证号:617673198109148314 +测试 object_key:以 `MD5('617673198109148314')` 为准 +主体点:10 +账户点:14 +持有边:14 +明细边:72 +``` + +测试覆盖: + +- 默认全部流水聚合。 +- 日期范围筛选聚合。 +- 支出方向 `flag = 1`。 +- 收入方向 `flag = 2`。 +- 家庭关系标签:配偶、父亲、母亲。 +- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。 +- 点击家庭关系节点按 `object_key` 设为中心查询。 + +## 16. 2026-05-29 最新验收口径 + +本节为当前最新口径,用于覆盖前文早期变更记录中的冲突描述。 + +当前图谱功能保留两类能力: + +- 资金流图谱:作为专项排查中的核心图谱能力,读取 `lx_fund_flow_subject_node`、`lx_fund_flow_account_node`、`lx_fund_flow_own_account_edge`、`lx_fund_flow_detail_edge`,并叠加 `lx_fund_flow_manual_edge` 手工资金流向。 +- 关系图谱:保留页面页签和接口能力,读取 `lx_rel_node`、`lx_rel_family_edge`、`lx_rel_stock_edge`、`lx_rel_represent_edge`,支持家庭关系、股东持股、法定代表人关系和疑似同名企业召回。 + +当前页面入口: + +- 项目详情“专项排查”页签展示完整图谱工作台,包含“资金流图谱”和“关系图谱”两个页签。 +- 项目分析弹窗“资金流向”页签展示简版资金流图谱。 +- 项目分析弹窗“关系图谱”页签展示简版关系图谱。 + +当前接口入口: + +```text +GET /ccdi/project/fund-graph/search +GET /ccdi/project/fund-graph/graph +GET /ccdi/project/fund-graph/edge-detail +POST /ccdi/project/fund-graph/manual-edge + +GET /ccdi/project/relation-graph/search +GET /ccdi/project/relation-graph/graph +GET /ccdi/project/relation-graph/suspected-enterprises +``` + +当前数据库执行口径: + +- 新环境可参考 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql` 和 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`。 +- 已建资金流图谱表的环境使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引,不删除、不重建、不清空基座数据。 +- 已建关系图谱表的环境使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑补字段和补索引。 +- 生产 DDL 和补充 SQL 都由人工确认后手动执行,不随应用发布自动执行。 + +当前验收样例: + +```text +资金流图谱测试身份证号:617673198109148314 +关系图谱测试身份证号:330101198001010011 +``` diff --git a/docs/plans/fullstack/2026-05-28-graph-production-db-change-list.md b/docs/plans/fullstack/2026-05-28-graph-production-db-change-list.md new file mode 100644 index 00000000..b477530e --- /dev/null +++ b/docs/plans/fullstack/2026-05-28-graph-production-db-change-list.md @@ -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`。 diff --git a/docs/reports/implementation/2026-05-28-fund-graph-special-check-implementation.md b/docs/reports/implementation/2026-05-28-fund-graph-special-check-implementation.md new file mode 100644 index 00000000..187b78c5 --- /dev/null +++ b/docs/reports/implementation/2026-05-28-fund-graph-special-check-implementation.md @@ -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_*` 表,后端接口会报表不存在。 diff --git a/docs/reports/implementation/2026-05-29-fund-graph-review-fixes-implementation.md b/docs/reports/implementation/2026-05-29-fund-graph-review-fixes-implementation.md new file mode 100644 index 00000000..9c2a3ded --- /dev/null +++ b/docs/reports/implementation/2026-05-29-fund-graph-review-fixes-implementation.md @@ -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` +- 真实页面验收重点: + - 手工新增资金流向缺少必填项时返回明确错误提示 + - 资金边“展开一层”按钮在流入/流出两类边下判断更符合对端主体实际可扩展性 + - 同一查询条件下真实边与手工边整体排序和数量上限一致 diff --git a/docs/reports/implementation/2026-05-29-fund-graph-statement-query-fix.md b/docs/reports/implementation/2026-05-29-fund-graph-statement-query-fix.md new file mode 100644 index 00000000..ae7a9723 --- /dev/null +++ b/docs/reports/implementation/2026-05-29-fund-graph-statement-query-fix.md @@ -0,0 +1,18 @@ +# 资金流图谱流水查询报错修复实施记录 + +## 本次修改 + +- 修复资金流图谱构建节点列表时,对边两端主体二次回查未设置 `limit` 的问题。 +- 该问题会使 MyBatis 生成 `LIMIT null`,MySQL 报 `near 'null'`,进而导致资金流图谱查询或点击资金边后的流水明细下钻显示失败。 +- 保留 Mapper 层默认 `LIMIT 20`,同时在服务层补齐默认上限,避免其他调用路径再次传入空分页上限。 + +## 影响范围 + +- 后端接口:`GET /ccdi/project/fund-graph/graph` +- 关联前端表现:资金流图谱加载和资金边流水明细查询 + +## 验证 + +- 执行后端编译,确认图谱模块通过编译。 +- 重新打包并按项目脚本重启后端,使运行中的 jar 包含本次修复。 +- 使用真实接口查询资金流图谱和资金边流水明细,确认不再出现 `LIMIT null` SQL 错误。 diff --git a/docs/reports/implementation/2026-05-29-graph-acceptance-doc-and-verification.md b/docs/reports/implementation/2026-05-29-graph-acceptance-doc-and-verification.md new file mode 100644 index 00000000..152c583c --- /dev/null +++ b/docs/reports/implementation/2026-05-29-graph-acceptance-doc-and-verification.md @@ -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`。 diff --git a/docs/reports/implementation/2026-05-29-graph-precommit-summary.md b/docs/reports/implementation/2026-05-29-graph-precommit-summary.md new file mode 100644 index 00000000..b5f83bad --- /dev/null +++ b/docs/reports/implementation/2026-05-29-graph-precommit-summary.md @@ -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/` 仍保持忽略,浏览器截图不纳入提交。 diff --git a/docs/tests/plans/2026-05-29-graph-acceptance-checklist.md b/docs/tests/plans/2026-05-29-graph-acceptance-checklist.md new file mode 100644 index 00000000..902fc904 --- /dev/null +++ b/docs/tests/plans/2026-05-29-graph-acceptance-checklist.md @@ -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 `。 +- 生产环境 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 版本。 +- [ ] 已建表环境执行补充前,先人工确认目标库、表结构差异和备份策略。 diff --git a/docs/tests/records/2026-05-29-graph-acceptance-record.md b/docs/tests/records/2026-05-29-graph-acceptance-record.md new file mode 100644 index 00000000..06693509 --- /dev/null +++ b/docs/tests/records/2026-05-29-graph-acceptance-record.md @@ -0,0 +1,101 @@ +# 2026-05-29 图谱功能验收执行记录 + +## 1. 执行信息 + +- 执行时间:2026-05-29 17:36 ~ 17:43(Asia/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 前人工核对目标库和表结构差异。 diff --git a/ruoyi-ui/src/api/ccdi/graph/fundGraph.js b/ruoyi-ui/src/api/ccdi/graph/fundGraph.js new file mode 100644 index 00000000..6ae9614d --- /dev/null +++ b/ruoyi-ui/src/api/ccdi/graph/fundGraph.js @@ -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, + }); +} diff --git a/ruoyi-ui/src/api/ccdi/graph/relationGraph.js b/ruoyi-ui/src/api/ccdi/graph/relationGraph.js new file mode 100644 index 00000000..ff462152 --- /dev/null +++ b/ruoyi-ui/src/api/ccdi/graph/relationGraph.js @@ -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, + }); +} diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue index 2071bcf6..6773904c 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue @@ -2,7 +2,7 @@ 重试 - + - + - + @@ -75,6 +89,7 @@ @@ -216,14 +242,14 @@ export default { .project-analysis-dialog__body { display: flex; flex-direction: column; - min-height: calc(96vh - 64px); + min-height: calc(92vh - 64px); border: 1px solid #dde3ec; background: #ffffff; overflow: hidden; } .project-analysis-header { - padding: 38px 54px 34px; + padding: 28px 40px 24px; border-bottom: 1px solid #dde3ec; background: #ffffff; } @@ -241,15 +267,15 @@ export default { .project-analysis-header__eyebrow { color: #65758d; - font-size: 17px; + font-size: 15px; font-weight: 700; line-height: 1; } .project-analysis-header__title { - margin-top: 24px; + margin-top: 18px; color: #101a2b; - font-size: 34px; + font-size: 28px; font-weight: 700; line-height: 1; } @@ -280,10 +306,10 @@ export default { .project-analysis-workspace { display: flex; align-items: flex-start; - gap: 56px; - min-height: 720px; - max-height: calc(96vh - 168px); - padding: 28px 54px 42px; + gap: 36px; + min-height: 640px; + max-height: calc(92vh - 150px); + padding: 20px 40px 28px; overflow: auto; background: #ffffff; } @@ -295,15 +321,15 @@ export default { } .project-analysis-layout__sidebar { - flex: 0 0 38%; - max-width: 520px; + flex: 0 0 34%; + max-width: 460px; } .project-analysis-layout__main { flex: 1; min-width: 0; border-left: 1px solid #dde3ec; - padding-left: 56px; + padding-left: 36px; } .project-analysis-layout__alert { @@ -343,12 +369,12 @@ export default { } .el-tabs__item { - height: 58px; - padding: 0 32px !important; + height: 52px; + padding: 0 24px !important; color: #2a374a; - font-size: 18px; + font-size: 16px; font-weight: 500; - line-height: 58px; + line-height: 52px; } .el-tabs__item.is-active { @@ -362,7 +388,7 @@ export default { } .el-tabs__content { - padding-top: 28px; + padding-top: 20px; } } diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisFundFlowTab.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisFundFlowTab.vue new file mode 100644 index 00000000..f7a9ad15 --- /dev/null +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisFundFlowTab.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue index 135d2e74..cf15c4de 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue @@ -22,22 +22,7 @@ @evidence-confirm="$emit('evidence-confirm', $event)" /> -
-
-
-
图谱外链展示
-
用于后续接入外链图谱页面
-
- 占位中 -
- -
-
- 当前卡片用于预留专项核查图谱入口,后续接入外链地址后可在此直接跳转展示。 -
- 待接入 -
-
+
@@ -51,12 +36,14 @@ import { createSpecialCheckLoadedData, specialCheckStateData } from "./specialCh import { getFamilyAssetLiabilityList } from "@/api/ccdi/projectSpecialCheck"; import ExtendedQuerySection from "./ExtendedQuerySection"; import FamilyAssetLiabilitySection from "./FamilyAssetLiabilitySection"; +import FundGraphSection from "./graph/FundGraphSection"; export default { name: "SpecialCheck", components: { ExtendedQuerySection, FamilyAssetLiabilitySection, + FundGraphSection, }, props: { projectId: { @@ -155,52 +142,6 @@ export default { 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 { margin-top: 16px; } diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/graph/FundGraphSection.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/graph/FundGraphSection.vue new file mode 100644 index 00000000..a7e4d2c1 --- /dev/null +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/graph/FundGraphSection.vue @@ -0,0 +1,2422 @@ + + + + + diff --git a/sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql b/sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql new file mode 100644 index 00000000..bcfb4d45 --- /dev/null +++ b/sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql @@ -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='资金流图谱手工资金流向汇总边表'; diff --git a/sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql b/sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql new file mode 100644 index 00000000..bece3528 --- /dev/null +++ b/sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql @@ -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; diff --git a/sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql b/sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql new file mode 100644 index 00000000..48bc8211 --- /dev/null +++ b/sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql @@ -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='关系图谱法定代表人边表'; diff --git a/sql/ccdi/graph/04_lx_relation_graph_build_mysql.sql b/sql/ccdi/graph/04_lx_relation_graph_build_mysql.sql new file mode 100644 index 00000000..a17e0935 --- /dev/null +++ b/sql/ccdi/graph/04_lx_relation_graph_build_mysql.sql @@ -0,0 +1,476 @@ +-- ============================================================ +-- 关系图谱 MySQL 构建脚本 +-- 说明: +-- 1. 执行前先执行 03_lx_relation_graph_mysql_ddl.sql。 +-- 2. 本脚本优先写入家庭关系点边,再写入工商股东/法人点边。 +-- 3. 点表和边表通过主键与聚合逻辑保证去重,可重复执行。 +-- 4. 家庭个人点 object_key = MD5(身份证号)。 +-- 5. 家庭边按两端 object_key 无向归并,夫妻双方都是员工时只保留一条边。 +-- 6. 工商源表沿用现有关系图谱 SQL:sjfx_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; diff --git a/sql/ccdi/graph/05_lx_relation_graph_seed_test_data.sql b/sql/ccdi/graph/05_lx_relation_graph_seed_test_data.sql new file mode 100644 index 00000000..86c56710 --- /dev/null +++ b/sql/ccdi/graph/05_lx_relation_graph_seed_test_data.sql @@ -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; diff --git a/sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql b/sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql new file mode 100644 index 00000000..0286516b --- /dev/null +++ b/sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql @@ -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;