# 信贷客户实体关联维护功能 - 后端实施方案 ## 一、功能概述 基于员工实体关系维护功能开发信贷客户实体关联维护功能,后端实现逻辑与员工实体关系完全一致,主要差异在于: 1. **不验证身份证号**:导入时不需要验证身份证号是否存在 2. **无远程搜索接口**:没有员工搜索功能 3. **身份标识默认值不同**:`is_cust_family = 1` --- ## 二、数据库设计 ### 2.1 表结构 表名:`ccdi_cust_enterprise_relation` ```sql CREATE TABLE `ccdi_cust_enterprise_relation` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,唯一标识', `person_id` VARCHAR(18) NOT NULL COMMENT '身份证号', `relation_person_post` VARCHAR(100) DEFAULT NULL COMMENT '关联人在企业的职务:股东、法人、高管、实际控制人等', `social_credit_code` VARCHAR(18) NOT NULL COMMENT '统一社会信用代码,关联企业主体信息表的外键', `enterprise_name` VARCHAR(200) DEFAULT NULL COMMENT '企业名称(冗余存储,便于快速查询)', `status` INT NOT NULL DEFAULT 1 COMMENT '关系是否有效:0 - 无效、1 - 有效(默认有效)', `remark` TEXT COMMENT '补充说明', `data_source` VARCHAR(50) DEFAULT NULL COMMENT '数据来源', `is_employee` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否是员工:0-否 1-是', `is_emp_family` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否是员工家庭关联人:0-否 1-是', `is_customer` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否是信贷客户:0-否 1-是', `is_cust_family` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否是信贷客户关联人:0-否 1-是', `created_by` VARCHAR(64) NOT NULL COMMENT '记录创建人', `updated_by` VARCHAR(64) DEFAULT NULL COMMENT '记录更新人', `create_time` DATETIME NOT NULL COMMENT '记录创建时间', `update_time` DATETIME NOT NULL COMMENT '记录更新时间', PRIMARY KEY (`id`), KEY `idx_person_id` (`person_id`), KEY `idx_social_credit_code` (`social_credit_code`), UNIQUE KEY `uk_person_enterprise` (`person_id`, `social_credit_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='信贷客户实体关联关系表'; ``` ### 2.2 唯一性约束 - 业务主键:`person_id` + `social_credit_code` 组合唯一 --- ## 三、后端文件清单 ### 3.1 Domain层 | 文件名 | 路径 | 说明 | |--------|------|------| | CcdiCustEnterpriseRelation.java | domain/ | 实体类 | | CcdiCustEnterpriseRelationVO.java | domain/vo/ | 视图对象 | | CcdiCustEnterpriseRelationAddDTO.java | domain/dto/ | 新增DTO | | CcdiCustEnterpriseRelationEditDTO.java | domain/dto/ | 编辑DTO | | CcdiCustEnterpriseRelationQueryDTO.java | domain/dto/ | 查询DTO | | CcdiCustEnterpriseRelationExcel.java | domain/excel/ | Excel导入导出对象 | | CustEnterpriseRelationImportFailureVO.java | domain/vo/ | 导入失败记录VO | ### 3.2 Mapper层 | 文件名 | 路径 | 说明 | |--------|------|------| | CcdiCustEnterpriseRelationMapper.java | mapper/ | Mapper接口 | | CcdiCustEnterpriseRelationMapper.xml | resources/mapper/ccdi/ | Mapper XML | ### 3.3 Service层 | 文件名 | 路径 | 说明 | |--------|------|------| | ICcdiCustEnterpriseRelationService.java | service/ | 服务接口 | | CcdiCustEnterpriseRelationServiceImpl.java | service/impl/ | 服务实现 | | ICcdiCustEnterpriseRelationImportService.java | service/ | 异步导入服务接口 | | CcdiCustEnterpriseRelationImportServiceImpl.java | service/impl/ | 异步导入服务实现 | ### 3.4 Controller层 | 文件名 | 路径 | 说明 | |--------|------|------| | CcdiCustEnterpriseRelationController.java | controller/ | 控制器 | --- ## 四、核心实现细节 ### 4.1 实体类 CcdiCustEnterpriseRelation.java ```java package com.ruoyi.ccdi.domain; import com.baomidou.mybatisplus.annotation.*; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; import java.util.Date; /** * 信贷客户实体关联信息对象 ccdi_cust_enterprise_relation */ @Data @TableName("ccdi_cust_enterprise_relation") @Schema(description = "信贷客户实体关联信息") public class CcdiCustEnterpriseRelation implements Serializable { @Serial private static final long serialVersionUID = 1L; /** 主键ID */ @TableId(type = IdType.AUTO) @Schema(description = "主键ID") private Long id; /** 身份证号 */ @Schema(description = "身份证号") private String personId; /** 关联人在企业的职务 */ @Schema(description = "关联人在企业的职务") private String relationPersonPost; /** 统一社会信用代码 */ @Schema(description = "统一社会信用代码") private String socialCreditCode; /** 企业名称 */ @Schema(description = "企业名称") private String enterpriseName; /** 状态(0-无效 1-有效) */ @Schema(description = "状态(0-无效 1-有效)") private Integer status; /** 补充说明 */ @Schema(description = "补充说明") private String remark; /** 数据来源 */ @Schema(description = "数据来源") private String dataSource; /** 是否为员工(0-否 1-是) */ @Schema(description = "是否为员工(0-否 1-是)") private Integer isEmployee; /** 是否为员工家属(0-否 1-是) */ @Schema(description = "是否为员工家属(0-否 1-是)") private Integer isEmpFamily; /** 是否为客户(0-否 1-是) */ @Schema(description = "是否为客户(0-否 1-是)") private Integer isCustomer; /** 是否为客户家属(0-否 1-是) */ @Schema(description = "是否为客户家属(0-否 1-是)") private Integer isCustFamily; /** 创建时间 */ @TableField(fill = FieldFill.INSERT) @Schema(description = "创建时间") private Date createTime; /** 更新时间 */ @TableField(fill = FieldFill.INSERT_UPDATE) @Schema(description = "更新时间") private Date updateTime; /** 创建人 */ @TableField(fill = FieldFill.INSERT) @Schema(description = "创建人") private String createdBy; /** 更新人 */ @TableField(fill = FieldFill.INSERT_UPDATE) @Schema(description = "更新人") private String updatedBy; } ``` ### 4.2 VO类 CcdiCustEnterpriseRelationVO.java **与员工实体关系VO的差异**: - 无 `personName` 字段(因为没有关联员工表查询姓名) - 类名和注释不同 ```java package com.ruoyi.ccdi.domain.vo; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; import java.util.Date; /** * 信贷客户实体关联信息VO */ @Data @Schema(description = "信贷客户实体关联信息") public class CcdiCustEnterpriseRelationVO implements Serializable { @Serial private static final long serialVersionUID = 1L; /** 主键ID */ @Schema(description = "主键ID") private Long id; /** 身份证号 */ @Schema(description = "身份证号") private String personId; /** 关联人在企业的职务 */ @Schema(description = "关联人在企业的职务") private String relationPersonPost; /** 统一社会信用代码 */ @Schema(description = "统一社会信用代码") private String socialCreditCode; /** 企业名称 */ @Schema(description = "企业名称") private String enterpriseName; /** 状态(0-无效 1-有效) */ @Schema(description = "状态(0-无效 1-有效)") private Integer status; /** 补充说明 */ @Schema(description = "补充说明") private String remark; /** 数据来源 */ @Schema(description = "数据来源") private String dataSource; /** 是否为员工(0-否 1-是) */ @Schema(description = "是否为员工(0-否 1-是)") private Integer isEmployee; /** 是否为员工家属(0-否 1-是) */ @Schema(description = "是否为员工家属(0-否 1-是)") private Integer isEmpFamily; /** 是否为客户(0-否 1-是) */ @Schema(description = "是否为客户(0-否 1-是)") private Integer isCustomer; /** 是否为客户家属(0-否 1-是) */ @Schema(description = "是否为客户家属(0-否 1-是)") private Integer isCustFamily; /** 创建时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @Schema(description = "创建时间") private Date createTime; /** 更新时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @Schema(description = "更新时间") private Date updateTime; /** 创建人 */ @Schema(description = "创建人") private String createdBy; /** 更新人 */ @Schema(description = "更新人") private String updatedBy; } ``` ### 4.3 新增DTO CcdiCustEnterpriseRelationAddDTO.java ```java package com.ruoyi.ccdi.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Data; import java.io.Serial; import java.io.Serializable; /** * 信贷客户实体关联信息新增DTO */ @Data @Schema(description = "信贷客户实体关联信息新增") public class CcdiCustEnterpriseRelationAddDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; /** 身份证号 */ @NotBlank(message = "身份证号不能为空") @Pattern(regexp = "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$", message = "身份证号格式不正确") @Schema(description = "身份证号") private String personId; /** 关联人在企业的职务 */ @Size(max = 100, message = "关联人在企业的职务长度不能超过100个字符") @Schema(description = "关联人在企业的职务") private String relationPersonPost; /** 统一社会信用代码 */ @NotBlank(message = "统一社会信用代码不能为空") @Pattern(regexp = "^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$", message = "统一社会信用代码格式不正确") @Schema(description = "统一社会信用代码") private String socialCreditCode; /** 企业名称 */ @NotBlank(message = "企业名称不能为空") @Size(max = 200, message = "企业名称长度不能超过200个字符") @Schema(description = "企业名称") private String enterpriseName; /** 状态(0-无效 1-有效) */ @Schema(description = "状态(0-无效 1-有效)") private Integer status; /** 补充说明 */ @Schema(description = "补充说明") private String remark; } ``` ### 4.4 编辑DTO CcdiCustEnterpriseRelationEditDTO.java ```java package com.ruoyi.ccdi.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; import java.io.Serial; import java.io.Serializable; /** * 信贷客户实体关联信息编辑DTO */ @Data @Schema(description = "信贷客户实体关联信息编辑") public class CcdiCustEnterpriseRelationEditDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; /** 主键ID */ @NotNull(message = "主键ID不能为空") @Schema(description = "主键ID") private Long id; /** 身份证号(不可修改) */ @Schema(description = "身份证号(不可修改)") private String personId; /** 关联人在企业的职务 */ @Size(max = 100, message = "关联人在企业的职务长度不能超过100个字符") @Schema(description = "关联人在企业的职务") private String relationPersonPost; /** 统一社会信用代码(不可修改) */ @Schema(description = "统一社会信用代码(不可修改)") private String socialCreditCode; /** 企业名称 */ @NotBlank(message = "企业名称不能为空") @Size(max = 200, message = "企业名称长度不能超过200个字符") @Schema(description = "企业名称") private String enterpriseName; /** 状态(0-无效 1-有效) */ @Schema(description = "状态(0-无效 1-有效)") private Integer status; /** 补充说明 */ @Schema(description = "补充说明") private String remark; } ``` ### 4.5 查询DTO CcdiCustEnterpriseRelationQueryDTO.java ```java package com.ruoyi.ccdi.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; /** * 信贷客户实体关联信息查询DTO */ @Data @Schema(description = "信贷客户实体关联信息查询条件") public class CcdiCustEnterpriseRelationQueryDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; /** 身份证号 */ @Schema(description = "身份证号") private String personId; /** 统一社会信用代码 */ @Schema(description = "统一社会信用代码") private String socialCreditCode; /** 企业名称 */ @Schema(description = "企业名称") private String enterpriseName; /** 状态(0-无效 1-有效) */ @Schema(description = "状态(0-无效 1-有效)") private Integer status; } ``` ### 4.6 Excel类 CcdiCustEnterpriseRelationExcel.java ```java package com.ruoyi.ccdi.domain.excel; import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.ruoyi.common.annotation.Required; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; /** * 信贷客户实体关联信息Excel导入导出对象 */ @Data @Schema(description = "信贷客户实体关联信息Excel导入导出对象") public class CcdiCustEnterpriseRelationExcel implements Serializable { @Serial private static final long serialVersionUID = 1L; /** 身份证号 */ @ExcelProperty(value = "身份证号", index = 0) @ColumnWidth(20) @Required @Schema(description = "身份证号") private String personId; /** 统一社会信用代码 */ @ExcelProperty(value = "统一社会信用代码", index = 1) @ColumnWidth(25) @Required @Schema(description = "统一社会信用代码") private String socialCreditCode; /** 企业名称 */ @ExcelProperty(value = "企业名称", index = 2) @ColumnWidth(30) @Required @Schema(description = "企业名称") private String enterpriseName; /** 关联人在企业的职务 */ @ExcelProperty(value = "关联人在企业的职务", index = 3) @ColumnWidth(25) @Schema(description = "关联人在企业的职务") private String relationPersonPost; /** 补充说明 */ @ExcelProperty(value = "补充说明", index = 4) @ColumnWidth(40) @Schema(description = "补充说明") private String remark; } ``` ### 4.7 导入失败VO CustEnterpriseRelationImportFailureVO.java ```java package com.ruoyi.ccdi.domain.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; /** * 信贷客户实体关联信息导入失败记录VO */ @Data @Schema(description = "信贷客户实体关联信息导入失败记录") public class CustEnterpriseRelationImportFailureVO implements Serializable { @Serial private static final long serialVersionUID = 1L; /** 身份证号 */ @Schema(description = "身份证号") private String personId; /** 统一社会信用代码 */ @Schema(description = "统一社会信用代码") private String socialCreditCode; /** 企业名称 */ @Schema(description = "企业名称") private String enterpriseName; /** 错误信息 */ @Schema(description = "错误信息") private String errorMessage; } ``` --- ## 五、Mapper层实现 ### 5.1 Mapper接口 CcdiCustEnterpriseRelationMapper.java ```java package com.ruoyi.ccdi.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.domain.CcdiCustEnterpriseRelation; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationQueryDTO; import com.ruoyi.ccdi.domain.vo.CcdiCustEnterpriseRelationVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; import java.util.Set; /** * 信贷客户实体关联信息 数据层 */ @Mapper public interface CcdiCustEnterpriseRelationMapper extends BaseMapper { /** * 分页查询信贷客户实体关联列表 */ Page selectRelationPage(@Param("page") Page page, @Param("query") CcdiCustEnterpriseRelationQueryDTO queryDTO); /** * 查询信贷客户实体关联详情 */ CcdiCustEnterpriseRelationVO selectRelationById(@Param("id") Long id); /** * 判断身份证号和统一社会信用代码的组合是否已存在 */ boolean existsByPersonIdAndSocialCreditCode(@Param("personId") String personId, @Param("socialCreditCode") String socialCreditCode); /** * 批量查询已存在的person_id + social_credit_code组合 */ Set batchExistsByCombinations(@Param("combinations") List combinations); /** * 批量插入信贷客户实体关联数据 */ int insertBatch(@Param("list") List list); } ``` ### 5.2 Mapper XML CcdiCustEnterpriseRelationMapper.xml **与员工实体关系Mapper XML的关键差异**: - 无 `personName` 字段查询 - 无 JOIN 员工表 - 表名不同 ```xml INSERT INTO ccdi_cust_enterprise_relation (person_id, relation_person_post, social_credit_code, enterprise_name, status, remark, data_source, is_employee, is_emp_family, is_customer, is_cust_family, created_by, create_time, updated_by, update_time) VALUES (#{item.personId}, #{item.relationPersonPost}, #{item.socialCreditCode}, #{item.enterpriseName}, #{item.status}, #{item.remark}, #{item.dataSource}, #{item.isEmployee}, #{item.isEmpFamily}, #{item.isCustomer}, #{item.isCustFamily}, #{item.createdBy}, NOW(), #{item.updatedBy}, NOW()) ``` --- ## 六、Service层实现 ### 6.1 服务接口 ICcdiCustEnterpriseRelationService.java ```java package com.ruoyi.ccdi.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationAddDTO; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationEditDTO; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationQueryDTO; import com.ruoyi.ccdi.domain.excel.CcdiCustEnterpriseRelationExcel; import com.ruoyi.ccdi.domain.vo.CcdiCustEnterpriseRelationVO; import java.util.List; /** * 信贷客户实体关联信息 服务层 */ public interface ICcdiCustEnterpriseRelationService { /** * 查询信贷客户实体关联列表 */ List selectRelationList(CcdiCustEnterpriseRelationQueryDTO queryDTO); /** * 分页查询信贷客户实体关联列表 */ Page selectRelationPage(Page page, CcdiCustEnterpriseRelationQueryDTO queryDTO); /** * 查询信贷客户实体关联列表(用于导出) */ List selectRelationListForExport(CcdiCustEnterpriseRelationQueryDTO queryDTO); /** * 查询信贷客户实体关联详情 */ CcdiCustEnterpriseRelationVO selectRelationById(Long id); /** * 新增信贷客户实体关联 */ int insertRelation(CcdiCustEnterpriseRelationAddDTO addDTO); /** * 修改信贷客户实体关联 */ int updateRelation(CcdiCustEnterpriseRelationEditDTO editDTO); /** * 批量删除信贷客户实体关联 */ int deleteRelationByIds(Long[] ids); /** * 导入信贷客户实体关联数据(异步) */ String importRelation(List excelList); } ``` ### 6.2 服务接口 ICcdiCustEnterpriseRelationImportService.java ```java package com.ruoyi.ccdi.service; import com.ruoyi.ccdi.domain.excel.CcdiCustEnterpriseRelationExcel; import com.ruoyi.ccdi.domain.vo.CustEnterpriseRelationImportFailureVO; import com.ruoyi.ccdi.domain.vo.ImportStatusVO; import java.util.List; /** * 信贷客户实体关联信息异步导入服务层 */ public interface ICcdiCustEnterpriseRelationImportService { /** * 异步导入信贷客户实体关联数据 */ void importRelationAsync(List excelList, String taskId, String userName); /** * 获取导入失败记录 */ List getImportFailures(String taskId); /** * 查询导入状态 */ ImportStatusVO getImportStatus(String taskId); } ``` ### 6.3 服务实现 CcdiCustEnterpriseRelationServiceImpl.java **关键差异点**: - 身份标识默认值:`is_cust_family = 1`(其他为0) - 无员工身份证号验证 ```java package com.ruoyi.ccdi.service.impl; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.domain.CcdiCustEnterpriseRelation; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationAddDTO; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationEditDTO; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationQueryDTO; import com.ruoyi.ccdi.domain.excel.CcdiCustEnterpriseRelationExcel; import com.ruoyi.ccdi.domain.vo.CcdiCustEnterpriseRelationVO; import com.ruoyi.ccdi.mapper.CcdiCustEnterpriseRelationMapper; import com.ruoyi.ccdi.service.ICcdiCustEnterpriseRelationImportService; import com.ruoyi.ccdi.service.ICcdiCustEnterpriseRelationService; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import jakarta.annotation.Resource; import org.springframework.beans.BeanUtils; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * 信贷客户实体关联信息 服务层处理 */ @Service public class CcdiCustEnterpriseRelationServiceImpl implements ICcdiCustEnterpriseRelationService { @Resource private CcdiCustEnterpriseRelationMapper relationMapper; @Resource private ICcdiCustEnterpriseRelationImportService relationImportService; @Resource private RedisTemplate redisTemplate; @Override public java.util.List selectRelationList(CcdiCustEnterpriseRelationQueryDTO queryDTO) { Page page = new Page<>(1, Integer.MAX_VALUE); Page resultPage = relationMapper.selectRelationPage(page, queryDTO); return resultPage.getRecords(); } @Override public Page selectRelationPage(Page page, CcdiCustEnterpriseRelationQueryDTO queryDTO) { return relationMapper.selectRelationPage(page, queryDTO); } @Override public java.util.List selectRelationListForExport(CcdiCustEnterpriseRelationQueryDTO queryDTO) { Page page = new Page<>(1, Integer.MAX_VALUE); Page resultPage = relationMapper.selectRelationPage(page, queryDTO); return resultPage.getRecords().stream().map(vo -> { CcdiCustEnterpriseRelationExcel excel = new CcdiCustEnterpriseRelationExcel(); BeanUtils.copyProperties(vo, excel); return excel; }).collect(Collectors.toList()); } @Override public CcdiCustEnterpriseRelationVO selectRelationById(Long id) { return relationMapper.selectRelationById(id); } @Override @Transactional public int insertRelation(CcdiCustEnterpriseRelationAddDTO addDTO) { // 检查身份证号+统一社会信用代码唯一性 if (relationMapper.existsByPersonIdAndSocialCreditCode(addDTO.getPersonId(), addDTO.getSocialCreditCode())) { throw new RuntimeException("该身份证号和统一社会信用代码组合已存在"); } CcdiCustEnterpriseRelation relation = new CcdiCustEnterpriseRelation(); BeanUtils.copyProperties(addDTO, relation); // 设置默认值 // 新增时强制设置状态为有效 relation.setStatus(1); // 【关键差异】信贷客户实体关联的身份标识默认值 if (relation.getIsEmployee() == null) { relation.setIsEmployee(0); } if (relation.getIsEmpFamily() == null) { relation.setIsEmpFamily(0); } if (relation.getIsCustomer() == null) { relation.setIsCustomer(0); } if (relation.getIsCustFamily() == null) { relation.setIsCustFamily(1); // 信贷客户关联人标识为1 } if (StringUtils.isEmpty(relation.getDataSource())) { relation.setDataSource("MANUAL"); } int result = relationMapper.insert(relation); return result; } @Override @Transactional public int updateRelation(CcdiCustEnterpriseRelationEditDTO editDTO) { // 使用LambdaUpdateWrapper只更新非null字段,保护系统字段不被覆盖 LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(CcdiCustEnterpriseRelation::getId, editDTO.getId()); // 只更新前端可编辑的字段 updateWrapper.set(editDTO.getRelationPersonPost() != null, CcdiCustEnterpriseRelation::getRelationPersonPost, editDTO.getRelationPersonPost()); updateWrapper.set(editDTO.getEnterpriseName() != null, CcdiCustEnterpriseRelation::getEnterpriseName, editDTO.getEnterpriseName()); updateWrapper.set(editDTO.getStatus() != null, CcdiCustEnterpriseRelation::getStatus, editDTO.getStatus()); updateWrapper.set(editDTO.getRemark() != null, CcdiCustEnterpriseRelation::getRemark, editDTO.getRemark()); // 注意:以下字段不可修改 // - personId(身份证号,业务主键) // - socialCreditCode(统一社会信用代码,业务主键) // - dataSource(数据来源,系统字段) // - isEmployee(是否为员工,系统字段) // - isEmpFamily(是否为员工家属,系统字段) // - isCustomer(是否为客户,系统字段) // - isCustFamily(是否为客户家属,系统字段) int result = relationMapper.update(null, updateWrapper); return result; } @Override @Transactional public int deleteRelationByIds(Long[] ids) { return relationMapper.deleteBatchIds(java.util.List.of(ids)); } @Override @Transactional public String importRelation(java.util.List excelList) { if (StringUtils.isNull(excelList) || excelList.isEmpty()) { throw new RuntimeException("至少需要一条数据"); } // 生成任务ID String taskId = UUID.randomUUID().toString(); long startTime = System.currentTimeMillis(); // 获取当前用户名 String userName = SecurityUtils.getUsername(); // 初始化Redis状态 String statusKey = "import:custEnterpriseRelation:" + taskId; Map statusData = new HashMap<>(); statusData.put("taskId", taskId); statusData.put("status", "PROCESSING"); statusData.put("totalCount", excelList.size()); statusData.put("successCount", 0); statusData.put("failureCount", 0); statusData.put("progress", 0); statusData.put("startTime", startTime); statusData.put("message", "正在处理..."); redisTemplate.opsForHash().putAll(statusKey, statusData); redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); // 调用异步导入服务 relationImportService.importRelationAsync(excelList, taskId, userName); return taskId; } } ``` ### 6.4 异步导入服务实现 CcdiCustEnterpriseRelationImportServiceImpl.java **【关键实现】异步导入核心逻辑** **与员工实体关系导入的关键差异**: - **不验证身份证号是否存在**(移除员工表验证逻辑) - 身份标识默认值:`is_cust_family = 1` - Redis key前缀:`import:custEnterpriseRelation:` ```java package com.ruoyi.ccdi.service.impl; import com.alibaba.fastjson2.JSON; import com.ruoyi.ccdi.domain.CcdiCustEnterpriseRelation; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationAddDTO; import com.ruoyi.ccdi.domain.excel.CcdiCustEnterpriseRelationExcel; import com.ruoyi.ccdi.domain.vo.CustEnterpriseRelationImportFailureVO; import com.ruoyi.ccdi.domain.vo.ImportResult; import com.ruoyi.ccdi.domain.vo.ImportStatusVO; import com.ruoyi.ccdi.mapper.CcdiCustEnterpriseRelationMapper; import com.ruoyi.ccdi.service.ICcdiCustEnterpriseRelationImportService; import com.ruoyi.ccdi.utils.ImportLogUtils; import com.ruoyi.common.utils.StringUtils; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * 信贷客户实体关联信息异步导入服务层处理 */ @Service @EnableAsync public class CcdiCustEnterpriseRelationImportServiceImpl implements ICcdiCustEnterpriseRelationImportService { private static final Logger log = LoggerFactory.getLogger(CcdiCustEnterpriseRelationImportServiceImpl.class); @Resource private CcdiCustEnterpriseRelationMapper relationMapper; @Resource private RedisTemplate redisTemplate; @Override @Async @Transactional public void importRelationAsync(List excelList, String taskId, String userName) { long startTime = System.currentTimeMillis(); // 记录导入开始 ImportLogUtils.logImportStart(log, taskId, "信贷客户实体关联", excelList.size(), userName); List newRecords = new ArrayList<>(); List failures = new ArrayList<>(); // 【关键差异】不需要验证身份证号是否存在 // 员工实体关系导入会验证身份证号是否存在于员工表,信贷客户实体关联不需要此验证 // 批量查询已存在的person_id + social_credit_code组合 ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的客户企业关系组合", excelList.size()); Set existingCombinations = getExistingCombinations(excelList); ImportLogUtils.logBatchQueryComplete(log, taskId, "客户企业关系组合", existingCombinations.size()); // 用于跟踪Excel文件内已处理的组合 Set processedCombinations = new HashSet<>(); // 分类数据 for (int i = 0; i < excelList.size(); i++) { CcdiCustEnterpriseRelationExcel excel = excelList.get(i); try { // 转换为AddDTO进行验证 CcdiCustEnterpriseRelationAddDTO addDTO = new CcdiCustEnterpriseRelationAddDTO(); BeanUtils.copyProperties(excel, addDTO); // 验证数据(不验证身份证号是否存在) validateRelationData(addDTO); String combination = excel.getPersonId() + "|" + excel.getSocialCreditCode(); CcdiCustEnterpriseRelation relation = new CcdiCustEnterpriseRelation(); BeanUtils.copyProperties(excel, relation); if (existingCombinations.contains(combination)) { // 组合已存在,直接报错 throw new RuntimeException(String.format("身份证号[%s]和统一社会信用代码[%s]的组合已存在,请勿重复导入", excel.getPersonId(), excel.getSocialCreditCode())); } else if (processedCombinations.contains(combination)) { // Excel文件内部重复 throw new RuntimeException(String.format("身份证号[%s]和统一社会信用代码[%s]的组合在导入文件中重复,已跳过此条记录", excel.getPersonId(), excel.getSocialCreditCode())); } else { relation.setCreatedBy(userName); relation.setUpdatedBy(userName); // 设置默认值 relation.setStatus(1); // 【关键差异】信贷客户实体关联的身份标识 relation.setIsEmployee(0); relation.setIsEmpFamily(0); relation.setIsCustomer(0); relation.setIsCustFamily(1); // 信贷客户关联人标识为1 relation.setDataSource("IMPORT"); newRecords.add(relation); processedCombinations.add(combination); // 标记为已处理 } // 记录进度 ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), newRecords.size(), failures.size()); } catch (Exception e) { CustEnterpriseRelationImportFailureVO failure = new CustEnterpriseRelationImportFailureVO(); BeanUtils.copyProperties(excel, failure); failure.setErrorMessage(e.getMessage()); failures.add(failure); // 记录验证失败日志 String keyData = String.format("身份证号=%s, 统一社会信用代码=%s, 企业名称=%s", excel.getPersonId(), excel.getSocialCreditCode(), excel.getEnterpriseName()); ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); } } // 批量插入新数据 if (!newRecords.isEmpty()) { ImportLogUtils.logBatchOperationStart(log, taskId, "插入", (newRecords.size() + 499) / 500, 500); saveBatch(newRecords, 500); } // 保存失败记录到Redis if (!failures.isEmpty()) { try { String failuresKey = "import:custEnterpriseRelation:" + taskId + ":failures"; redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); } catch (Exception e) { ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e); } } ImportResult result = new ImportResult(); result.setTotalCount(excelList.size()); result.setSuccessCount(newRecords.size()); result.setFailureCount(failures.size()); // 更新最终状态 String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; updateImportStatus(taskId, finalStatus, result); // 记录导入完成 long duration = System.currentTimeMillis() - startTime; ImportLogUtils.logImportComplete(log, taskId, "信贷客户实体关联", excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); } @Override public List getImportFailures(String taskId) { String key = "import:custEnterpriseRelation:" + taskId + ":failures"; Object failuresObj = redisTemplate.opsForValue().get(key); if (failuresObj == null) { return Collections.emptyList(); } return JSON.parseArray(JSON.toJSONString(failuresObj), CustEnterpriseRelationImportFailureVO.class); } @Override public ImportStatusVO getImportStatus(String taskId) { String key = "import:custEnterpriseRelation:" + taskId; Boolean hasKey = redisTemplate.hasKey(key); if (Boolean.FALSE.equals(hasKey)) { throw new RuntimeException("任务不存在或已过期"); } Map statusMap = redisTemplate.opsForHash().entries(key); ImportStatusVO statusVO = new ImportStatusVO(); statusVO.setTaskId((String) statusMap.get("taskId")); statusVO.setStatus((String) statusMap.get("status")); statusVO.setTotalCount((Integer) statusMap.get("totalCount")); statusVO.setSuccessCount((Integer) statusMap.get("successCount")); statusVO.setFailureCount((Integer) statusMap.get("failureCount")); statusVO.setProgress((Integer) statusMap.get("progress")); statusVO.setStartTime((Long) statusMap.get("startTime")); statusVO.setEndTime((Long) statusMap.get("endTime")); statusVO.setMessage((String) statusMap.get("message")); return statusVO; } /** * 更新导入状态 */ private void updateImportStatus(String taskId, String status, ImportResult result) { String key = "import:custEnterpriseRelation:" + taskId; Map statusData = new HashMap<>(); statusData.put("status", status); statusData.put("successCount", result.getSuccessCount()); statusData.put("failureCount", result.getFailureCount()); statusData.put("progress", 100); statusData.put("endTime", System.currentTimeMillis()); if ("SUCCESS".equals(status)) { statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据"); } else { statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); } redisTemplate.opsForHash().putAll(key, statusData); } /** * 批量查询已存在的person_id + social_credit_code组合 */ private Set getExistingCombinations(List excelList) { List combinations = excelList.stream() .map(excel -> excel.getPersonId() + "|" + excel.getSocialCreditCode()) .filter(Objects::nonNull) .distinct() .collect(Collectors.toList()); if (combinations.isEmpty()) { return Collections.emptySet(); } return new HashSet<>(relationMapper.batchExistsByCombinations(combinations)); } /** * 批量保存 */ private void saveBatch(List list, int batchSize) { for (int i = 0; i < list.size(); i += batchSize) { int end = Math.min(i + batchSize, list.size()); List subList = list.subList(i, end); relationMapper.insertBatch(subList); } } /** * 验证信贷客户实体关联数据 * 【关键差异】不验证身份证号是否存在于员工表 */ private void validateRelationData(CcdiCustEnterpriseRelationAddDTO addDTO) { // 验证必填字段 if (StringUtils.isEmpty(addDTO.getPersonId())) { throw new RuntimeException("身份证号不能为空"); } if (StringUtils.isEmpty(addDTO.getSocialCreditCode())) { throw new RuntimeException("统一社会信用代码不能为空"); } if (StringUtils.isEmpty(addDTO.getEnterpriseName())) { throw new RuntimeException("企业名称不能为空"); } // 验证身份证号格式(18位) if (!addDTO.getPersonId().matches("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$")) { throw new RuntimeException("身份证号格式不正确,必须为18位有效身份证号"); } // 验证统一社会信用代码格式(18位) if (!addDTO.getSocialCreditCode().matches("^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$")) { throw new RuntimeException("统一社会信用代码格式不正确,必须为18位有效统一社会信用代码"); } // 验证字段长度 if (StringUtils.isNotEmpty(addDTO.getRelationPersonPost()) && addDTO.getRelationPersonPost().length() > 100) { throw new RuntimeException("关联人在企业的职务长度不能超过100个字符"); } if (addDTO.getEnterpriseName().length() > 200) { throw new RuntimeException("企业名称长度不能超过200个字符"); } // 【注意】不验证身份证号是否存在于员工表 } } ``` --- ## 七、Controller层实现 ### 7.1 CcdiCustEnterpriseRelationController.java ```java package com.ruoyi.ccdi.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationAddDTO; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationEditDTO; import com.ruoyi.ccdi.domain.dto.CcdiCustEnterpriseRelationQueryDTO; import com.ruoyi.ccdi.domain.excel.CcdiCustEnterpriseRelationExcel; import com.ruoyi.ccdi.domain.vo.CcdiCustEnterpriseRelationVO; import com.ruoyi.ccdi.domain.vo.CustEnterpriseRelationImportFailureVO; import com.ruoyi.ccdi.domain.vo.ImportResultVO; import com.ruoyi.ccdi.domain.vo.ImportStatusVO; import com.ruoyi.ccdi.service.ICcdiCustEnterpriseRelationImportService; import com.ruoyi.ccdi.service.ICcdiCustEnterpriseRelationService; import com.ruoyi.ccdi.utils.EasyExcelUtil; import com.ruoyi.common.annotation.Log; 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 com.ruoyi.common.enums.BusinessType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; import java.util.List; /** * 信贷客户实体关联信息Controller */ @Tag(name = "信贷客户实体关联信息管理") @RestController @RequestMapping("/ccdi/custEnterpriseRelation") public class CcdiCustEnterpriseRelationController extends BaseController { @Resource private ICcdiCustEnterpriseRelationService relationService; @Resource private ICcdiCustEnterpriseRelationImportService relationImportService; /** * 查询信贷客户实体关联列表 */ @Operation(summary = "查询信贷客户实体关联列表") @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:list')") @GetMapping("/list") public TableDataInfo list(CcdiCustEnterpriseRelationQueryDTO queryDTO) { PageDomain pageDomain = TableSupport.buildPageRequest(); Page page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize()); Page result = relationService.selectRelationPage(page, queryDTO); return getDataTable(result.getRecords(), result.getTotal()); } /** * 导出信贷客户实体关联列表 */ @Operation(summary = "导出信贷客户实体关联列表") @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:export')") @Log(title = "信贷客户实体关联信息", businessType = BusinessType.EXPORT) @PostMapping("/export") public void export(HttpServletResponse response, CcdiCustEnterpriseRelationQueryDTO queryDTO) { List list = relationService.selectRelationListForExport(queryDTO); EasyExcelUtil.exportExcel(response, list, CcdiCustEnterpriseRelationExcel.class, "信贷客户实体关联信息"); } /** * 获取信贷客户实体关联详细信息 */ @Operation(summary = "获取信贷客户实体关联详细信息") @Parameter(name = "id", description = "主键ID", required = true) @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:query')") @GetMapping(value = "/{id}") public AjaxResult getInfo(@PathVariable Long id) { return success(relationService.selectRelationById(id)); } /** * 新增信贷客户实体关联 */ @Operation(summary = "新增信贷客户实体关联") @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:add')") @Log(title = "信贷客户实体关联信息", businessType = BusinessType.INSERT) @PostMapping public AjaxResult add(@Validated @RequestBody CcdiCustEnterpriseRelationAddDTO addDTO) { return toAjax(relationService.insertRelation(addDTO)); } /** * 修改信贷客户实体关联 */ @Operation(summary = "修改信贷客户实体关联") @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:edit')") @Log(title = "信贷客户实体关联信息", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@Validated @RequestBody CcdiCustEnterpriseRelationEditDTO editDTO) { return toAjax(relationService.updateRelation(editDTO)); } /** * 删除信贷客户实体关联 */ @Operation(summary = "删除信贷客户实体关联") @Parameter(name = "ids", description = "主键ID数组", required = true) @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:remove')") @Log(title = "信贷客户实体关联信息", businessType = BusinessType.DELETE) @DeleteMapping("/{ids}") public AjaxResult remove(@PathVariable Long[] ids) { return toAjax(relationService.deleteRelationByIds(ids)); } /** * 下载导入模板 */ @Operation(summary = "下载导入模板") @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiCustEnterpriseRelationExcel.class, "信贷客户实体关联信息"); } /** * 异步导入信贷客户实体关联 */ @Operation(summary = "异步导入信贷客户实体关联") @Parameter(name = "file", description = "导入文件", required = true) @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:import')") @Log(title = "信贷客户实体关联信息", businessType = BusinessType.IMPORT) @PostMapping("/importData") public AjaxResult importData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiCustEnterpriseRelationExcel.class); if (list == null || list.isEmpty()) { return error("至少需要一条数据"); } // 提交异步任务 String taskId = relationService.importRelation(list); // 立即返回 ImportResultVO result = new ImportResultVO(); result.setTaskId(taskId); result.setStatus("PROCESSING"); result.setMessage("导入任务已提交,正在后台处理"); return AjaxResult.success("导入任务已提交,正在后台处理", result); } /** * 查询导入状态 */ @Operation(summary = "查询导入状态") @Parameter(name = "taskId", description = "任务ID", required = true) @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:import')") @GetMapping("/importStatus/{taskId}") public AjaxResult getImportStatus(@PathVariable String taskId) { ImportStatusVO statusVO = relationImportService.getImportStatus(taskId); return success(statusVO); } /** * 查询导入失败记录 */ @Operation(summary = "查询导入失败记录") @Parameter(name = "taskId", description = "任务ID", required = true) @Parameter(name = "pageNum", description = "页码", required = false) @Parameter(name = "pageSize", description = "每页条数", required = false) @PreAuthorize("@ss.hasPermi('ccdi:custEnterpriseRelation:import')") @GetMapping("/importFailures/{taskId}") public TableDataInfo getImportFailures( @PathVariable String taskId, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { List failures = relationImportService.getImportFailures(taskId); // 手动分页 int fromIndex = (pageNum - 1) * pageSize; int toIndex = Math.min(fromIndex + pageSize, failures.size()); if (fromIndex >= failures.size()) { return getDataTable(new ArrayList<>(), failures.size()); } List pageData = failures.subList(fromIndex, toIndex); return getDataTable(pageData, failures.size()); } } ``` --- ## 八、与员工实体关系代码对比 ### 8.1 关键差异总结 | 对比项 | 员工实体关系 | 信贷客户实体关联 | |--------|-------------|-----------------| | 表名 | ccdi_staff_enterprise_relation | ccdi_cust_enterprise_relation | | VO中是否有personName | 有(JOIN员工表) | 无(不JOIN) | | 身份证号验证 | 验证存在于员工表 | 不验证 | | 员工搜索功能 | 有 | 无 | | 身份标识默认值 | is_emp_family=1 | is_cust_family=1 | | Redis key前缀 | import:staffEnterpriseRelation: | import:custEnterpriseRelation: | | 权限标识 | ccdi:staffEnterpriseRelation:* | cdi:custEnterpriseRelation:* | | API路径 | /ccdi/staffEnterpriseRelation/* | /ccdi/custEnterpriseRelation/* | ### 8.2 导入逻辑对比 | 步骤 | 员工实体关系 | 信贷客户实体关联 | |------|-------------|-----------------| | 1. 验证必填字段 | 相同 | 相同 | | 2. 验证格式 | 相同 | 相同 | | 3. 验证身份证号存在 | **验证** | **不验证** | | 4. 检查组合唯一性 | 相同 | 相同 | | 5. 设置身份标识 | is_emp_family=1 | is_cust_family=1 | | 6. 批量插入 | 相同 | 相同 | --- ## 九、实施步骤 1. 执行数据库建表SQL 2. 创建Domain层文件(Entity、VO、DTO、Excel) 3. 创建Mapper层文件(Mapper接口、Mapper XML) 4. 创建Service层文件(Service接口、Service实现) 5. 创建Controller层文件 6. 配置菜单权限(需执行菜单SQL) 7. 编译测试