Skip to content

第10章 投标放款

放款业务概述

当一个标的已经筹集到了所借的全部资金,即为“满标”。此时P2P平台管理员会进行审核,审核通过后,P2P平台会把投资人的出借资金打入借款人在平台的账户中,这就叫“放款”,此时借款人贷款成功。平台放款流程如下:

  1. 管理员审核满标标的

  1. 确认审核结果

  1. 审核通过,放款成功

投标放款需求分析

当一个标的已经筹集到了所借的全部资金,即为“满标”。此时 P2P 平台管理员会进行审核,审核通过后,P2P 平台会把投资人的出借资金打入借款人在平台的账户中,这就叫“放款”,此时就表示借款人贷款成功。

系统交互流程图

系统交互流程如下图所示:

在该业务中会新增一个还款微服务:为平台提供还款计划的生成、执行、记录与归档等功能。

业务流程简述

第一阶段:生成放款明细(图中1-2)

  1. 前端向交易中心发起满标复审通过请求
  2. 交易中心汇总标的以及投标信息,形成放款明细

第二阶段:放款(图中3-12)

  1. 交易中心请求存管代理服务进行放款操作
  2. 存管代理服务对放款明细进行签名,并保存交易记录为未同步
  3. 存管代理服务请求存管系统进行放款
  4. 存管系统根据放款明细,将多个投资人冻结余额划至借款人账户,并更新投标信息状态为已放款
  5. 返回放款结果给存管代理服务
  6. 存管代理服务更新交易记录为已同步,并返回放款结果给交易中心
  7. 交易中心更新所有投标信息结果为:已放款

第三阶段:修改标的业务状态(图中13-21)

  1. 交易中心请求存管代理服务修改标的状态
  2. 存管代理服务对数据进行签名,并保存交易记录为:未同步
  3. 存管代理服务请求存管系统修改标的状态
  4. 存管系统修改标的为状态为:已放款,并返回结果给存管代理服务
  5. 存管代理服务更新交易记录为:已同步,返回修改成功给交易中心
  6. 交易中心收到返回结果,修改标的状态为:还款中

第四阶段:启动还款(图中22-25)

  1. 交易中心请求还款服务启动还款
  2. 还款服务收到请求后生成还款计划和应收明细,并返回启动还款成功给交易中心
  3. 交易中心返回放款成功给前端

搭建还款微服务工程

导入内容搜索微服务基础工程

导入 wanxinp2p-repayment-service 基础工程。此微服务负责还款相关的内容

Apollo 配置

  • 在 Apollo 中创建 repayment-service 项目存储内容检索微服务工程的相关配置,关联相关公共的命名空间,并对部分项目的配置进行修改覆盖,关联清单如下:
    • micro_service.spring-boot-http 命名空间需修改,修改项如下:
      • 项目根路径:server.servlet.context-path = /repayment
    • micro_service.spring-eureka
    • micro_service.spring-cloud-feign
    • micro_service.spring-ribbon
    • micro_service.mybatis-plus
      • 包扫描路径:mybatis-plus.typeAliasesPackage = com.moon.wanxinp2p.repayment.entity
    • micro_service.spring-rocketmq
    • micro_service.spring-boot-druid
      • 数据库连接地址:spring.datasource.url = jdbc:mysql://localhost:3306/p2p_repayment?useUnicode=true&useSSL=false
  • 修改 application 命名空间,新增相关配置,具体如下:
properties
swagger.enable = true
spring.mvc.throw-exception-if-no-handler-found = true

项目启动参数配置

配置以下 VM options 参数,启动服务,服务的端口是 53080

bash
-Denv=dev -Dapollo.cluster=DEFAULT -Dserver.port=53080

分别启动 apollo 服务、wanxinp2p-discover-server 和 wanxinp2p-repayment-service 测试是否正常

满标放款

接口定义

此部分接口定义全部都在 wanxinp2p-api 工程中

交易中心满标放款接口

TransactionApi 接口中新增 loansApprovalStatus 方法

java
/**
 * 审核标的满标放
 *
 * @param id            标的id
 * @param approveStatus 审核状态
 * @param commission    平台佣金
 * @return
 */
RestResponse<String> loansApprovalStatus(Long id, String approveStatus, String commission);

存管代理服务确认放款接口

  • 在 depository 模块中,创建标的满标放款信息请求实体类和放款明细请求信息实体类
java
@Data
public class LoanDetailRequest {
    /**
     * 放款金额
     */
    private BigDecimal amount;
    /**
     * 预处理业务流水号
     */
    private String preRequestNo;
}
java
@Data
public class LoanRequest {
    /**
     * 放款明细
     */
    private List<LoanDetailRequest> details;
    /**
     * 平台佣金
     */
    private BigDecimal commission;
    /**
     * 标的编码
     */
    private String projectNo;
    /**
     * 请求流水号
     */
    private String requestNo;

    /**
     * 操作对象id
     */
    private Long id;
}
  • DepositoryAgentApi 接口中新增 confirmLoan 审核标的满标放款方法
java
/**
 * 审核标的满标放款
 *
 * @param loanRequest
 * @return
 */
RestResponse<String> confirmLoan(LoanRequest loanRequest);

存管代理服务修改标的状态接口

  • 在 transaction 模块中,创建修改标的状态DTO实体类
java
@Data
public class ModifyProjectStatusDTO {
    /**
     * 请求流水号
     */
    private String requestNo;
    /**
     * 标的号
     */
    private String projectNo;
    /**
     * 更新标的状态
     */
    private String projectStatus;

    /**
     * 业务实体id
     */
    private Long id;
}
  • DepositoryAgentApi 接口中新增 modifyProjectStatus 修改标的状态的方法
java
/**
 * 修改标的状态
 *
 * @param modifyProjectStatusDTO
 * @return
 */
RestResponse<String> modifyProjectStatus(ModifyProjectStatusDTO modifyProjectStatusDTO);

还款服务启动还款接口

  • 在 wanxinp2p-api 工程中新建 repayment.model 包,创建标的还款信息dto类
java
@Data
public class ProjectWithTendersDTO {
    /**
     * 标的信息
     */
    private ProjectDTO project;
    /**
     * 标的对应的所有投标记录
     */
    private List<TenderDTO> tenders;

    /**
     * 投资人让出利率 ( 投资人让利 )
     */
    private BigDecimal commissionInvestorAnnualRate;

    /**
     * 借款人给平台的利率 ( 借款人让利 )
     */
    private BigDecimal commissionBorrowerAnnualRate;
}
  • 在 repayment 包中新建 RepaymentApi 接口,并定义 startRepayment 启动还款方法
java
public interface RepaymentApi {
    /**
     * 启动还款
     *
     * @param projectWithTendersDTO
     * @return
     */
    RestResponse<String> startRepayment(ProjectWithTendersDTO projectWithTendersDTO);
}

交易中心满标放款

接口功能描述

  1. 接受前端满标放款信息
  2. 交易中心根据标的信息生成还款明细
  3. 交易中心请求存管代理服务进行满标放款
  4. 交易中心收到放款成功结果后,更新投标信息状态为:已放款
  5. 交易中心请求存管代理服务修改标的状态
  6. 交易中心请求还款服务启动放款

远程调用接口

DepositoryAgentApiAgent 接口中,新增远程调用存管代理服务,确认放款与修改标的状态的方法

java
// path 属性用于定义该接口中所有方法的请求前缀
@FeignClient(value = "depository-agent-service", path = "/depository-agent")
public interface DepositoryAgentApiAgent {
    // ....省略

    @PostMapping("/l/confirm-loan")
    RestResponse<String> confirmLoan(@RequestBody LoanRequest loanRequest);

    @PostMapping("/l/modify-project-status")
    RestResponse<String> modifyProjectStatus(@RequestBody ModifyProjectStatusDTO modifyProjectStatusDTO);
}

注:因为接口中的方法越来越多,所以使用 @FeignClient 注解的 path 来定义请求 url 的前缀,即接口中所有方法的请求 url 都会拼接上 path 所指定的前缀

业务层

  • 修改 ProjectService 接口,新增 loansApprovalStatus 审核标的满标放款方法
java
/**
 * 审核标的满标放款
 *
 * @param id
 * @param approveStatus
 * @param commission
 * @return String
 */
String loansApprovalStatus(Long id, String approveStatus, String commission);
  • ProjectServiceImpl 类中实现该方法
java
@Override
public String loansApprovalStatus(Long id, String approveStatus, String commission) {
    /* 1. 生成放款明细 */
    // 根据标的id查询标的信息
    Project project = this.getById(id);
    // 根据标的id查询投标记录(集合)
    List<Tender> tenderList = tenderMapper.selectList(
            Wrappers.<Tender>lambdaQuery().eq(Tender::getProjectId, id)
    );
    // 封装放款明细 LoanRequest
    LoanRequest loanRequest = generateLoanRequest(project, tenderList, commission);

    /* 2. 调用存管代理服务确认放款 */
    RestResponse<String> confirmLoanResponse = depositoryAgentApiAgent.confirmLoan(loanRequest);
    if (!DepositoryReturnCode.RETURN_CODE_00000.getCode().equals(confirmLoanResponse.getResult())) {
        log.warn("请求存管代理服务确认放款失败 ! 标的ID为: {}, 存管代理服务返回的状态为: {}", id, confirmLoanResponse.getResult());
        throw new BusinessException(TransactionErrorCode.E_150113);
    }
    // 存管代理服务确认放款后,修改投标信息的状态为:已放款
    List<Long> tenderIds = tenderList.stream().map(Tender::getId).collect(Collectors.toList());
    // 根据多个id 更新
    // 方式一:不创建 Tender 对象
    tenderMapper.update(null, Wrappers.<Tender>lambdaUpdate()
            .set(Tender::getTenderStatus, TradingCode.LOAN.getCode())
            .in(Tender::getId, tenderIds));
    // 方式二:创建 Tender 对象
    /*tenderMapper.update(new Tender().setTenderStatus(TradingCode.LOAN.getCode()),
            Wrappers.<Tender>lambdaUpdate().in(Tender::getId, tenderIds));*/

    /* 3. 调用存管代理服务修改标的状态 */
    // 创建请求参数对象 ModifyProjectStatusDTO
    ModifyProjectStatusDTO modifyProjectStatusDTO = new ModifyProjectStatusDTO();
    modifyProjectStatusDTO.setId(project.getId()); // 标的id
    modifyProjectStatusDTO.setProjectStatus(ProjectCode.REPAYING.getCode()); // 标的状态 -- 还款中
    modifyProjectStatusDTO.setRequestNo(loanRequest.getRequestNo()); // 请求流水号
    modifyProjectStatusDTO.setProjectNo(project.getProjectNo()); // 标的编号

    // 请求存管代理服务更新标的状态
    RestResponse<String> modifyStatusResponse = depositoryAgentApiAgent.modifyProjectStatus(modifyProjectStatusDTO);
    if (!DepositoryReturnCode.RETURN_CODE_00000.getCode().equals(modifyStatusResponse.getResult())) {
        log.warn("请求存管代理服务更新标的状态失败 ! 标的ID为: {}, 存管代理服务返回的状态为: {}", id, modifyStatusResponse.getResult());
        throw new BusinessException(TransactionErrorCode.E_150113);
    }

    // 处理成功,修改本地数据库 p2p_transaction_*.project_* 表中的标的状态为还款中
    project.setProjectStatus(ProjectCode.REPAYING.getCode());
    updateById(project);

    /* 4. 调用还款服务启动还款(生成还款计划、应收明细) */
    // 准备请求数据
    ProjectWithTendersDTO projectWithTendersDTO = new ProjectWithTendersDTO();
    // 设置标的信息
    ProjectDTO projectDTO = new ProjectDTO();
    BeanUtils.copyProperties(project, projectDTO);
    projectWithTendersDTO.setProject(projectDTO);
    // 设置投标信息
    List<TenderDTO> tenderDTOList = tenderList.stream().map(t -> {
        TenderDTO dto = new TenderDTO();
        BeanUtils.copyProperties(t, dto);
        return dto;
    }).collect(Collectors.toList());
    projectWithTendersDTO.setTenders(tenderDTOList);
    // 设置投资人让利率
    projectWithTendersDTO.setCommissionInvestorAnnualRate(configService.getCommissionInvestorAnnualRate());
    // 设置借款人让利率
    projectWithTendersDTO.setCommissionBorrowerAnnualRate(configService.getBorrowerAnnualRate());

    // 调用还款服务(注:这里涉及分布式事务,目前暂时留空)

    return "审核成功";
}

/**
 * 根据标的和投标信息生成放款明细
 */
public LoanRequest generateLoanRequest(Project project, List<Tender> tenderList, String commission) {
    LoanRequest loanRequest = new LoanRequest();

    // 设置标的id
    loanRequest.setId(project.getId());
    // 设置平台佣金
    if (StringUtils.hasText(commission)) {
        loanRequest.setCommission(new BigDecimal(commission));
    }
    // 设置标的编号
    loanRequest.setProjectNo(project.getProjectNo());
    // 设置请求流水号
    loanRequest.setRequestNo(CodeNoUtil.getNo(CodePrefixCode.CODE_REQUEST_PREFIX));
    // 设置放款明细 Tender 转 LoanDetailRequest
    loanRequest.setDetails(tenderList.stream().map(t -> {
        LoanDetailRequest loanDetail = new LoanDetailRequest();
        // 放款金额
        loanDetail.setAmount(t.getAmount());
        // 预处理业务流水号
        loanDetail.setPreRequestNo(t.getRequestNo());
        return loanDetail;
    }).collect(Collectors.toList()));

    return loanRequest;
}

控制层

修改 TransactionController 类,指定请求 url 与调用业务层方法

java
@ApiOperation("审核标的满标放款")
@ApiImplicitParams({
        @ApiImplicitParam(name = "id", value = "标的id", required = true, dataType = "long", paramType = "path"),
        @ApiImplicitParam(name = "approveStatus", value = "标的状态", required = true, dataType = "string", paramType = "path"),
        @ApiImplicitParam(name = "commission", value = "平台佣金", required = true, dataType = "string", paramType = "query")
})
@PutMapping("/m/loans/{id}/projectStatus/{approveStatus}")
@Override
public RestResponse<String> loansApprovalStatus(@PathVariable("id") Long id,
                                                @PathVariable("approveStatus") String approveStatus,
                                                String commission) {
    return RestResponse.success(projectService.loansApprovalStatus(id, approveStatus, commission));
}

存管代理服务确认放款

接口功能描述

  1. 接受确认放款数据
  2. 保存请求记录
  3. 生成签名数据
  4. 请求存管系统进行确认放款

业务层

  • DepositoryRecordService 接口中新增 confirmLoan 方法
java
/**
 * 审核满标放款
 *
 * @param loanRequest
 * @return
 */
DepositoryResponseDTO<DepositoryBaseResponse> confirmLoan(LoanRequest loanRequest);
  • DepositoryRecordServiceImpl 类中实现该方法
java
@Override
public DepositoryResponseDTO<DepositoryBaseResponse> confirmLoan(LoanRequest loanRequest) {
    // 创建 DepositoryRecord 记录对象,设置必要的属性
    String requestNo = loanRequest.getRequestNo();
    DepositoryRecord depositoryRecord = new DepositoryRecord()
            .setRequestNo(requestNo) // 设置请求流水号
            .setRequestType(DepositoryRequestTypeCode.FULL_LOAN.getCode()) // 设置请求类型
            .setObjectType("LoanRequest") // 设置关联业务实体类型
            .setObjectId(loanRequest.getId()); // 设置关联业务实体标识

    // 保存交易记录(实现幂等性)
    DepositoryResponseDTO<DepositoryBaseResponse> responseDTO = handleIdempotent(depositoryRecord);
    if (responseDTO != null) {
        return responseDTO;
    }

    // 重新查询交易记录
    depositoryRecord = getEntityByRequestNo(requestNo);

    // 对请求数据进行签名
    String jsonString = JSON.toJSONString(loanRequest);
    String reqData = EncryptUtil.encodeUTF8StringBase64(jsonString);

    // 使用 OKHttpClient 发送 Http 请求向银行存管系统发送数据(确认放款),根据结果修改状态并返回结果
    return sendHttpGet("CONFIRM_LOAN", reqData, depositoryRecord);
}

控制层

修改 DepositoryAgentController 类,指定请求 url 与调用业务层方法

java
@ApiOperation(value = "审核标的满标放款")
@ApiImplicitParam(name = "loanRequest", value = "标的满标放款信息", required = true,
        dataType = "LoanRequest", paramType = "body")
@PostMapping("l/confirm-loan")
@Override
public RestResponse<String> confirmLoan(@RequestBody LoanRequest loanRequest) {
    return getRestResponse(depositoryRecordService.confirmLoan(loanRequest));
}

存管代理服务修改标的状态

接口功能描述

  1. 接受修改标的状态数据
  2. 保存请求记录
  3. 生成签名数据
  4. 请求存管系统修改标的状态

业务层

  • DepositoryRecordService 接口中新增 modifyProjectStatus 方法
java
/**
 * 修改标的状态
 *
 * @param modifyProjectStatusDTO
 * @return
 */
DepositoryResponseDTO<DepositoryBaseResponse> modifyProjectStatus(ModifyProjectStatusDTO modifyProjectStatusDTO);
  • DepositoryRecordServiceImpl 类中实现该方法
java
@Override
public DepositoryResponseDTO<DepositoryBaseResponse> modifyProjectStatus(ModifyProjectStatusDTO modifyProjectStatusDTO) {
    // 创建 DepositoryRecord 记录对象,设置必要的属性
    String requestNo = modifyProjectStatusDTO.getRequestNo();
    DepositoryRecord depositoryRecord = new DepositoryRecord()
            .setRequestNo(requestNo) // 设置请求流水号
            .setRequestType(DepositoryRequestTypeCode.MODIFY_STATUS.getCode()) // 设置请求类型
            .setObjectType("Project") // 设置关联业务实体类型
            .setObjectId(modifyProjectStatusDTO.getId()); // 设置关联业务实体标识

    // 保存交易记录(实现幂等性)
    DepositoryResponseDTO<DepositoryBaseResponse> responseDTO = handleIdempotent(depositoryRecord);
    if (responseDTO != null) {
        return responseDTO;
    }

    // 重新查询交易记录
    depositoryRecord = getEntityByRequestNo(requestNo);

    // 对请求数据进行签名
    String jsonString = JSON.toJSONString(modifyProjectStatusDTO);
    String reqData = EncryptUtil.encodeUTF8StringBase64(jsonString);

    // 使用 OKHttpClient 发送 Http 请求向银行存管系统发送数据(更新标的状态),根据结果修改状态并返回结果
    return sendHttpGet("MODIFY_PROJECT", reqData, depositoryRecord);
}

控制层

修改 DepositoryAgentController 类,指定请求 url 与调用业务层方法

java
@ApiOperation(value = "修改标的状态")
@ApiImplicitParam(name = "modifyProjectStatusDTO", value = "修改标的状态DTO", required = true,
        dataType = "ModifyProjectStatusDTO", paramType = "body")
@PostMapping("l/modify-project-status")
@Override
public RestResponse<String> modifyProjectStatus(@RequestBody ModifyProjectStatusDTO modifyProjectStatusDTO) {
    return getRestResponse(depositoryRecordService.modifyProjectStatus(modifyProjectStatusDTO));
}

还款服务启动还款

接口功能描述

  1. 接受交易中心的还款信息
  2. 生成借款人还款计划,保存到数据库
  3. 生成投资人应收明细,保存到数据库

番外:还款方式简介

目前万信金融平台对借款人默认采用了等额本息的还款方式。等额本息是指,将借款本金和利息总额之和等月拆分,借款人每月偿还相同数额的本息部分。等额本息法最重要的一个特点是每月的还款额相同,从本质上来说是本金所占比例逐月递增,利息所占比例逐月递减,而每月还款总额是不变的。其计算公式为:

月利率 = 借款年利率 / 12
每月还款额 = 借款本金×[月利率×(1+月利率)^借款月数] / {[(1+月利率)^借款月数]-1}
第n月利息 = 本金*月利率*[(1+月利率)^借款月数 - (1+月利率)^(n - 1)] / {[(1+月利率)^借款月数]-1}

数据访问层

  • 在 wanxinp2p-repayment-service 工程的 mapper 包中,分别分别借款人还款计划 PlanMapper 接口与投资人应收明细 ReceivablePlanMapper 接口,均继承 MP 的 BaseMapper
java
/** 借款人还款计划 Mapper 接口 */
public interface PlanMapper extends BaseMapper<RepaymentPlan> {
}

/** 投资人应收明细Mapper接口 */
public interface ReceivablePlanMapper extends BaseMapper<ReceivablePlan> {
}
  • 分别创建 mapper 接口的映射配置文件 PlanMapper.xml 与 ReceivablePlanMapper.xml
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.moon.wanxinp2p.repayment.mapper.PlanMapper">
</mapper>
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.moon.wanxinp2p.repayment.mapper.ReceivablePlanMapper">
</mapper>

业务层

  • 新建 RepaymentService 接口,并在该接口中定义 startRepayment 开启还款的方法
java
public interface RepaymentService {
    /**
     * 启动还款
     *
     * @param projectWithTendersDTO
     * @return
     */
    String startRepayment(ProjectWithTendersDTO projectWithTendersDTO);
}
  • 新建 RepaymentServiceImpl 类,并在该类中实现 startRepayment 方法。注:因为此业务类中需要对两个表进行操作,所以不需要继承 mp 的通用 service 实现
java
@Service
public class RepaymentServiceImpl implements RepaymentService {

    @Autowired
    private PlanMapper planMapper;

    @Autowired
    private ReceivablePlanMapper receivablePlanMapper;

    /**
     * 启动还款
     *
     * @param projectWithTendersDTO
     * @return
     */
    @Override
    public String startRepayment(ProjectWithTendersDTO projectWithTendersDTO) {
        /* 1. 生成借款人还款计划 */
        // 获取标的信息
        ProjectDTO projectDTO = projectWithTendersDTO.getProject();
        // 获取投标信息
        List<TenderDTO> tenderDTOList = projectWithTendersDTO.getTenders();

        // 计算还款的月数
        int month = Double.valueOf(Math.ceil(projectDTO.getPeriod() / 30.0)).intValue();

        // 获取还款方式(目前项目只针对等额本息)
        String repaymentWay = projectDTO.getRepaymentWay();
        // 生成还款计划(项目目前只支持“等额本息”还款方式,所以使其他方式暂时抛出业务异常,以后再进行开发)
        if (!RepaymentWayCode.FIXED_REPAYMENT.getCode().equals(repaymentWay)) {
            throw new BusinessException(RepaymentErrorCode.E_170104);
        }
        // 还款计划
        EqualInterestRepayment fixedRepayment = RepaymentUtil.fixedRepayment(projectDTO.getAmount(), projectDTO.getBorrowerAnnualRate(), month, projectDTO.getCommissionAnnualRate());
        // 保存还款计划
        List<RepaymentPlan> planList = saveRepaymentPlan(projectDTO, fixedRepayment);

        /* 2. 生成投资人应收明细 */
        // 根据投标信息生成应收明细
        tenderDTOList.forEach(tenderDTO -> {
            // 生成应收明细列表(目前项目只针对等额本息)
            EqualInterestRepayment receipts = RepaymentUtil.fixedRepayment(tenderDTO.getAmount(), tenderDTO.getProjectAnnualRate(), month, projectWithTendersDTO.getCommissionInvestorAnnualRate());

            // 保存应收明细到数据库,每个投标人每次应收生成一条应收明细记录
            planList.forEach(plan -> saveRreceivablePlan(plan, tenderDTO, receipts));
        });

        return DepositoryReturnCode.RETURN_CODE_00000.getCode();
    }

    /**
     * 保存还款计划到数据库,并返回还款计划列表数据
     *
     * @param projectDTO
     * @param fixedRepayment
     * @return
     */
    private List<RepaymentPlan> saveRepaymentPlan(ProjectDTO projectDTO, EqualInterestRepayment fixedRepayment) {
        // 获取每期利息
        final Map<Integer, BigDecimal> interestMap = fixedRepayment.getInterestMap();
        // 平台收取利息
        final Map<Integer, BigDecimal> commissionMap = fixedRepayment.getCommissionMap();
        // 获取每期本金,将循环生成还款的记录列表。批量新增记录到 repayment_plan 借款人还款计划表后,并返回
        return fixedRepayment.getPrincipalMap().entrySet()
                .stream()
                .map(entry -> {
                    Integer key = entry.getKey();
                    // 创建还款计划实体类
                    final RepaymentPlan repaymentPlan = new RepaymentPlan();
                    // 标的id
                    repaymentPlan.setProjectId(projectDTO.getId());
                    // 发标人用户标识
                    repaymentPlan.setConsumerId(projectDTO.getConsumerId());
                    // 发标人用户编码
                    repaymentPlan.setUserNo(projectDTO.getUserNo());
                    // 标的编码
                    repaymentPlan.setProjectNo(projectDTO.getProjectNo());
                    // 期数
                    repaymentPlan.setNumberOfPeriods(key);
                    // 当期还款利息
                    repaymentPlan.setInterest(interestMap.get(key));
                    // 还款本金
                    repaymentPlan.setPrincipal(entry.getValue());
                    // 本息 = 本金 + 利息
                    repaymentPlan.setAmount(repaymentPlan.getPrincipal().add(repaymentPlan.getInterest()));
                    // 应还时间 = 当前时间 + 期数( 单位月 )
                    repaymentPlan.setShouldRepaymentDate(DateUtil.localDateTimeAddMonth(DateUtil.now(), key));
                    // 应还状态, 当前业务为待还
                    repaymentPlan.setRepaymentStatus("0");
                    // 计划创建时间
                    repaymentPlan.setCreateDate(DateUtil.now());
                    // 设置平台佣金( 借款人让利 ) 注意这个地方是 具体佣金
                    repaymentPlan.setCommission(commissionMap.get(key));

                    // 保存到数据库(这里为了方便,循环插入,实际项目尽量避免这种操作)
                    planMapper.insert(repaymentPlan);
                    return repaymentPlan;
                }).collect(Collectors.toList());
    }

    /**
     * 保存应收明细到数据库
     *
     * @param plan
     * @param tender
     * @param receipts
     */
    private void saveRreceivablePlan(RepaymentPlan plan, TenderDTO tender, EqualInterestRepayment receipts) {
        // 应收本金
        final Map<Integer, BigDecimal> principalMap = receipts.getPrincipalMap();
        // 应收利息
        final Map<Integer, BigDecimal> interestMap = receipts.getInterestMap();
        // 平台收取利息
        final Map<Integer, BigDecimal> commissionMap = receipts.getCommissionMap();

        // 创建投资人应收明细实体对象,补充相关属性
        ReceivablePlan receivablePlan = new ReceivablePlan();
        // 投标信息标识
        receivablePlan.setTenderId(tender.getId());
        // 设置期数
        receivablePlan.setNumberOfPeriods(plan.getNumberOfPeriods());
        // 投标人用户标识
        receivablePlan.setConsumerId(tender.getConsumerId());
        // 投标人用户编码
        receivablePlan.setUserNo(tender.getUserNo());
        // 还款计划项标识
        receivablePlan.setRepaymentId(plan.getId());
        // 应收利息
        receivablePlan.setInterest(interestMap.get(plan.getNumberOfPeriods()));
        // 应收本金
        receivablePlan.setPrincipal(principalMap.get(plan.getNumberOfPeriods()));
        // 应收本息 = 应收本金 + 应收利息
        receivablePlan.setAmount(receivablePlan.getInterest().add(receivablePlan.getPrincipal()));
        // 应收时间
        receivablePlan.setShouldReceivableDate(plan.getShouldRepaymentDate());
        // 应收状态, 当前业务为未收
        receivablePlan.setReceivableStatus(0);
        // 创建时间
        receivablePlan.setCreateDate(DateUtil.now());
        // 设置投资人让利, 注意这个地方是具体佣金
        receivablePlan.setCommission(commissionMap.get(plan.getNumberOfPeriods()));
        // 保存到数据库
        receivablePlanMapper.insert(receivablePlan);
    }
}

控制层

创建 RepaymentController 控制类,指定请求 url 与调用业务层方法

java
@RestController
public class RepaymentController implements RepaymentApi {

    @Autowired
    private RepaymentService repaymentService;

    /**
     * 启动还款
     *
     * @param projectWithTendersDTO
     * @return
     */
    @ApiOperation("启动还款")
    @ApiImplicitParam(name = "projectWithTendersDTO", value = "通过id获取标的信息", required = true,
            dataType = "ProjectWithTendersDTO", paramType = "body")
    @PostMapping("/l/start-repayment")
    @Override
    public RestResponse<String> startRepayment(@RequestBody ProjectWithTendersDTO projectWithTendersDTO) {
        return RestResponse.success(repaymentService.startRepayment(projectWithTendersDTO));
    }
}

前后端集成测试

启动服务

  1. 启动 Apollo 服务
  2. 启动 es 服务与 Logstash 服务
  3. redis 服务
  4. 后端需要启动:
    • wanxinp2p-discover-server 微服务
    • wanxinp2p-gateway-server 微服务
    • wanxinp2p-uaa-service 微服务
    • wanxinp2p-account-service 微服务
    • wanxinp2p-consumer-service 微服务
    • wanxinp2p-transaction-service 微服务
    • wanxinp2p-content-search-service 微服务
    • wanxinp2p-depository-agent-service 微服务
    • wanxinp2p-repayment-service 微服务
    • wanxindepository 银行微服务
  5. 启动前端 wanxinp2p-web-app 与 wanxinp2p-web-manager 项目

测试步骤

测试数据:

  1. 挑选或准备一个已经审核通过的新标
  2. 至少挑选两个投资人登录并进行投标,使其满标
  3. 保证借款人和投资人已经开户并且账户余额充足

或者直接使用之前发标和投标产生的数据,但是要确保数据规范、正确、有效

访问使用 admin 账号登陆

测试功能:

  1. 查看存管代理服务是否保存交易记录,状态是否从“未同步”改为“已同步”
  2. 查看交易中心是否修改投标信息状态为“已放款”

  1. 查看交易中心是否修改标的状态为“还款中”

  1. 查看还款服务是否生成并保存还款计划和应收明细(等到后面再测试,需要启动RocketMQ)

满标放款业务中的分布式事务问题

满标放款功能涉及到分布式事务问题,需要使用 RocketMQ 事务消息实现最终一致性事务。

通过 RocketMQ 实现可靠消息最终一致性事务的概述与基础示例详见《RocketMQ 可靠消息最终一致性事务解决方案》

问题分析

在满标放款业务中(上图红框部分),交易中心修改标的状态为“还款中”,同时要通知还款服务生成还款计划和应收明细。两者为原子性绑定,即:标的状态修改成功,就必须生成还款计划和应收明细。由于涉及到两个独立的微服务,这里就存在分布式事务问题。

还款微服务生成还款计划和应收明细很有可能是一个较为耗时的业务,不建议阻塞主业务流程,并且此业务对强一致性要求较低,因此可以采用 RocketMQ 可靠消息实现最终一致性这个解决方案。

RocketMQ 可靠消息实现最终一致性流程图

消息发送方:交易中心微服务

项目依赖

wanxinp2p-transaction-service 交易中心微服务 pom.xml 的添加 RocketMQ 依赖

xml
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>

项目配置

检查 Apollo 中交易中心项目的 micro_service.spring-rocketmq 名称空间配置

properties
rocketmq.producer.group = producer_start_repayment
rocketmq.name-server = 127.0.0.1:9876

业务功能发送消息

  • 在 wanxinp2p-common 工程中,创建项目的MQ消息相关的常量类
java
public final class P2PMqConstants {
    /**
     * 启动还款消息主题
     */
    public final static String TOPIC_START_REPAYMENT = "TP_START_REPAYMENT";
}
  • 创建 P2pTransactionProducer 类,实现交易中心发送消息
java
@Component
public class P2pTransactionProducer {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public void updateProjectStatusAndStartRepayment(Project project, ProjectWithTendersDTO projectWithTendersDTO) {
        // 1.构造消息
        JSONObject object = new JSONObject();
        object.put("project", project);
        object.put("projectWithTendersDTO", projectWithTendersDTO);
        // 将标的信息与标的还款信息保存到消息体中
        Message<String> msg = MessageBuilder.withPayload(object.toJSONString()).build();

        // 2.发送消息
        rocketMQTemplate.sendMessageInTransaction("PID_START_REPAYMENT", P2PMqConstants.TOPIC_START_REPAYMENT, msg, null);
    }
}
  • 修改 ProjectServiceImpl.loansApprovalStatus 审核标的方法,在方法最后增加给还款服务发送消息
java
...
@Autowired
private P2pTransactionProducer p2pTransactionProducer;
...

@Override
public String loansApprovalStatus(Long id, String approveStatus, String commission) {
    ....
    // 调用还款服务(注:这里涉及分布式事务,使用 RocketMQ 实现事务最终一致)
    p2pTransactionProducer.updateProjectStatusAndStartRepayment(project, projectWithTendersDTO);
    return "审核成功";
}
  • 修改 ProjectService 接口,新增 updateProjectStatusAndStartRepayment 标的状态的方法,此方法也是 RocketMQ 发送半消息后执行的本地事务操作
java
/**
 * 修改标的状态为还款中
 *
 * @param project
 * @return
 */
boolean updateProjectStatusAndStartRepayment(Project project);

ProjectServiceImpl 类实现该方法

java
@Transactional(rollbackFor = Exception.class)
@Override
public boolean updateProjectStatusAndStartRepayment(Project project) {
    // 如果处理成功,就修改标的状态为还款中
    project.setProjectStatus(ProjectCode.REPAYING.getCode());
    // 更新数据库表
    return this.updateById(project);
}

RocketMQ 事务消息监听器

创建 RocketMQ 事务消息监听器 P2pTransactionListenerImpl,需要实现 org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener 接口,并在类上标识 @RocketMQTransactionListener 注解,其中 txProducerGroup 属性是用于指定监听的消息分组名称

实现接口中的方法,功能分别是:

  • executeLocalTransaction:该方法执行本地事务,会在发送半消息后,被 RocketMQ 自动调用
  • checkLocalTransaction:该方法实现事务回查,会在无法收到确认消息时,被 RocketMQ 自动调用。此方法中会去查询标的状态是否为还款中,如果是,则说明事务成功。
java
@Component
// txProducerGroup 属性指定消息的分组(注:与 P2pTransactionProducer 类中发送方法的分组名称一致)
@RocketMQTransactionListener(txProducerGroup = "PID_START_REPAYMENT")
@Slf4j
public class P2pTransactionListenerImpl implements RocketMQLocalTransactionListener {

    @Autowired
    private ProjectService projectService;

    @Autowired
    private ProjectMapper projectMapper;

    /**
     * 执行本地事务
     *
     * @param msg
     * @param arg
     * @return
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        // 1. 解析消息
        final JSONObject jsonObject = JSON.parseObject(new String((byte[]) msg.getPayload()));
        Project project = JSONObject.parseObject(jsonObject.getString("project"), Project.class);

        // 2. 执行本地事务
        boolean result = projectService.updateProjectStatusAndStartRepayment(project);

        // 3. 返回执行结果
        return result ? RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
    }

    /**
     * 执行事务回查
     *
     * @param msg
     * @return
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        log.info("P2pTransactionListenerImpl 执行事务回查");
        // 1. 解析消息
        final JSONObject jsonObject = JSON.parseObject(new String((byte[]) msg.getPayload()));
        Project project = JSONObject.parseObject(jsonObject.getString("project"), Project.class);

        // 2. 查询标的状态
        Project projectDb = projectMapper.selectById(project.getId());

        // 3. 返回执行结果(如标的状态是还款中,则代表事务成功执行)
        return ProjectCode.REPAYING.getCode().equals(projectDb.getProjectStatus()) ? 
                RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
    }
}

消息接收方:还款微服务

更新数据库表结构

修改 p2p_repayment 数据库的 repayment_plan 表,添加唯一索引,利用唯一索引实现幂等性

sql
USE `p2p_repayment`;
ALTER TABLE `p2p_repayment`.`repayment_plan` ADD UNIQUE INDEX `plan_unique` ( `CONSUMER_ID`, `PROJECT_ID`, `NUMBER_OF_PERIODS` );

业务层还款功能

修改业务层实现类 RepaymentServiceImplstartRepayment 方法,添加 @Transactional(rollbackFor = Exception.class) 注解,让方法实现本地事务

RocketMQ 事务消息监听器

创建 RocketMQ 事务消息监听器,该类需要实现 org.apache.rocketmq.spring.core.RocketMQListener 接口,并标识 @RocketMQMessageListener 注解,其中 topic 属性用于指定消息的主题,需要与交易中心发送方定义的主题一致。

实现 onMessage 方法, 主要的业务逻辑是,收到消息后,解析消息,并调用业务层启动还款方法。

java
@Component
@RocketMQMessageListener(topic = P2PMqConstants.TOPIC_START_REPAYMENT, consumerGroup = "CID_START_REPAYMENT")
@Slf4j
public class StartRepaymentMessageConsumer implements RocketMQListener<String> {

    @Autowired
    private RepaymentService repaymentService;

    @Override
    public void onMessage(String msg) {
        log.info("StartRepaymentMessageConsumer 接收到消息: {}", msg);
        // 1.解析消息
        final JSONObject jsonObject = JSON.parseObject(msg);
        ProjectWithTendersDTO projectWithTendersDTO =
                JSONObject.parseObject(jsonObject.getString("projectWithTendersDTO"), ProjectWithTendersDTO.class);

        // 2.调用业务层执行本地事务
        repaymentService.startRepayment(projectWithTendersDTO);
    }
}

功能测试

启动服务

  1. 启动 Apollo 服务
  2. 启动 es 服务与 Logstash 服务
  3. redis 服务
  4. RocketMQ 服务
  5. 后端需要启动:
    • wanxinp2p-discover-server 微服务
    • wanxinp2p-gateway-server 微服务
    • wanxinp2p-uaa-service 微服务
    • wanxinp2p-account-service 微服务
    • wanxinp2p-consumer-service 微服务
    • wanxinp2p-transaction-service 微服务
    • wanxinp2p-content-search-service 微服务
    • wanxinp2p-depository-agent-service 微服务
    • wanxinp2p-repayment-service 微服务
    • wanxindepository 银行微服务
  6. 启动前端 wanxinp2p-web-app 与 wanxinp2p-web-manager 项目

测试步骤

  1. 检查 Apollo 配置(交易中心):

图中这三个数据不能为空,不能为0。

  1. 测试场景
  • 交易中心执行本地事务失败,则还款服务接收不到消息。
  • 交易中心执行完本地事务后,不返回任何信息,则Broker会进行事务回查。
  • 还款服务执行本地事务失败,会进行重试消费。
  • 交易中心和还款服务都成功。查询数据库是否生成还款计划相关数据

I know where dry desert ends, green grass grows · MooN