支付宝周期扣款逻辑梳理和代码流程设计

周期扣款支付后签约场景文档

支付宝周期扣款产品介绍

业务流程

  1. 请求支付字符串时携带签约信息拉起支付,并创建待签约的订阅表信息
  2. 处理签约成功回调,添加到订阅表
  3. 定时任务自行请求订阅表,把达到扣款日期的订阅,然后请求支付宝扣款,再计算下次扣款时间
  4. 处理签约解除回调,修改订阅表数据状态。(需要去设置网关回调地址)

数据表设计

1. 用户周期扣款订阅表

CREATE TABLE `customer_period_subscribe` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `app_id` bigint(20) NOT NULL DEFAULT '0',
  `customer_id` bigint(20) NOT NULL DEFAULT '0',
  `vip_config_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '签约时购买的vip配置ID',
  `channel` varchar(20) NOT NULL DEFAULT '',
  `version` varchar(20) NOT NULL DEFAULT '',
  `oaid` varchar(100) NOT NULL DEFAULT '',
  `contract_no` varchar(100) NOT NULL DEFAULT '' COMMENT '支付宝商家本地唯一签约号',
  `contract_time` datetime DEFAULT NULL COMMENT '签约成功时间',
  `cancel_time` datetime DEFAULT NULL COMMENT '解约时间',
  `contract_status` int(11) NOT NULL DEFAULT '0' COMMENT '订阅状态,0未订阅,1签约中,2已订阅,-1已退订',
  `agreement_no` varchar(255) NOT NULL DEFAULT '' COMMENT '支付宝平台签约成功返回签约号',
  `next_pay_date` varchar(20) NOT NULL DEFAULT '' COMMENT '商户系统下次扣款日期',
  `contract_next_pay_date` varchar(20) NOT NULL DEFAULT '' COMMENT '签约时第三方系统下次扣款日期',
  `pay_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '签约扣款价格',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=35 DEFAULT CHARSET=utf8mb4 COMMENT='用户周期购签约表';

2. 周期扣款日志表

CREATE TABLE `customer_period_pay_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `app_id` bigint(20) NOT NULL DEFAULT '0',
  `customer_id` bigint(20) NOT NULL DEFAULT '0',
  `period_subscribe_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '周期购订阅记录ID',
  `pay_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '支付记录ID',
  `subject` varchar(100) NOT NULL DEFAULT '' COMMENT '周期扣款描述',
  `resp_json` text NOT NULL COMMENT '请求周期扣款接口响应数据',
  `pay_status` int(11) NOT NULL DEFAULT '0' COMMENT '扣款状态,-1扣款失败,1扣款成功',
  `date` varchar(20) NOT NULL DEFAULT '' COMMENT '执行日期',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COMMENT='周期扣款记录表';

3. 支付宝周期扣款签约回调表

CREATE TABLE `alipay_sign_callback` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `status` int(11) NOT NULL DEFAULT '0' COMMENT '回调情况 0未处理 1处理已完成',
  `callback_json` text COMMENT '整个订单数据序列化,后续需要再拿出来使用',
  `external_agreement_no` varchar(100) NOT NULL DEFAULT '' COMMENT '支付宝商家签约号',
  `callback_status` varchar(20) NOT NULL DEFAULT '' COMMENT '回调状态;正常:NORMAL,解约:UNSIGN,暂存,协议未生效过:TEMP,暂停:STOP',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付宝周期购签约回调表';

接周期扣款要注意的点

  1. 支付宝的周期扣款,后续的扣款是商家自行请求扣款接口的,支付宝是不会帮你们做定时器然后回调接口提示你已经扣款的。需要你自己写定时任务计算好扣款日期,再去请求支付宝的,然后支付宝可以提前5天扣款。

  2. 周期扣款日期不能是28号到月底最后一天的,假设下次扣款日是9月28日,那么建议你设置扣款日期是下个月的1~3号,也就是这个字段:execute_time

  3. 周期扣款的后续,商家自行请求支付宝时候,每笔扣款是100元内,也就是你接入周期扣款的时候,后续的每笔自动扣款都必须是100元内,没得提升,想要提升额度就是要用商家代扣,具体问问alipay客服。

代码层

创建支付订单

// 1. 创建用户待签约订购记录数据 customer_period_subscribe

Config config = new Config();
config.protocol = "https";
config.gatewayHost = "openapi.alipay.com";
config.signType = "RSA2";
config.appId = application.getAlipayAppId();
config.merchantPrivateKey = application.getAlipayMchPrivateKey();
config.alipayPublicKey = application.getAlipayPublicKey();
// 可设置异步通知接收服务地址(可选)
config.notifyUrl = alipayCallbackUrl;
// 设置参数
Factory.setOptions(config);

AlipayTradeAppPayResponse response;

try {

  String subject = "会员支付";

 //签约接入的方式
  Map<String, String> accessParams = new HashMap<>();
  accessParams.put("channel", "ALIPAYAPP");

  //签约规则
  Map<String, Object> periodRuleParams = new HashMap<>();
  //周期类型枚举值为 DAY 和 MONTH  periodRuleParams.put("period_type", "MONTH");
  //周期数,与 period_type 组合使用确定扣款周期
  periodRuleParams.put("period", vipConfig.getMonthNumber());
  //用户签约后,下一次使用代扣支付扣款的时间,支付宝周期扣不能大于 28号, 如果周期扣款当天计算是大于本月28号的,建议设置到下个月的1~3号
  periodRuleParams.put("execute_time", alipayExecuteTime);
  //周期扣款每笔限制扣款最大金额,目前支付宝最大是100元上限
  periodRuleParams.put("single_amount", vipConfig.getAlipayPrice());

  Map<String, Object> agreementSignParams = new HashMap<>();
  //个人签约产品码固定为CYCLE_PAY_AUTH_P
  agreementSignParams.put("personal_product_code", "CYCLE_PAY_AUTH_P");
  //协议签约场景,参见下文sign_scene参数说明
  agreementSignParams.put("sign_scene", "INDUSTRY|BOOKKEEPING");
  //签约接入的方式
  agreementSignParams.put("access_params", accessParams);
  //签约规则
  agreementSignParams.put("period_rule_params", periodRuleParams);
  //商户签约号,代扣协议中标示用户的唯一签约号(确保在商户系统中唯一)。
  //格式规则:支持大写小写字母和数字,最长32位。
  //商户系统按需传入,如果同一用户在同一产品码、同一签约场景下,签订了多份代扣协议,那么需要指定并传入该值。
  agreementSignParams.put("external_agreement_no", contractNo);
  // 签约成功回调地址,需注意,解约时的回调地址是回调应用网关
  agreementSignParams.put("sign_notify_url", alipaySignCallbackUrl);

  // 2. 发起API调用
  response = Factory.Payment.App()
       // 周期扣款固定产品码
      .optional("product_code", "CYCLE_PAY_AUTH")
      // 签约参数
      .optional("agreement_sign_params", agreementSignParams)
      .pay(subject, pay.getOutTradeNo(), totalAmount);

  // 3. 处理响应或异常
  if (!ResponseChecker.success(response)) {
         log.error("支付宝调用失败");
         throw new BaseException(SystemErrorType.SYSTEM_BUSY);
  }

} catch (Throwable e) {
    log.error("支付宝调用失败,原因:" + e.getMessage());
    throw new BaseException(SystemErrorType.SYSTEM_BUSY);
}

签约结果回调

/**
 * 支付宝周期扣款签约结果回调
 *
 * @param request
 * @return
 * @throws Exception
 */
@PostMapping(value = "/alipaySignNotify")
public String alipaySignNotify(HttpServletRequest request) throws Exception {

    //获取支付宝POST过来反馈信息
    Map<String, String> params = new HashMap<String, String>();
    Map requestParams = request.getParameterMap();
    for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext(); ) {
        String name = (String) iter.next();
        String[] values = (String[]) requestParams.get(name);
        String valueStr = "";
        for (int i = 0; i < values.length; i++) {
            valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ",";
        }
        //乱码解决,这段代码在出现乱码时使用。
        //valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
        params.put(name, valueStr);
    }

    log.error("支付宝周期扣款签约成功回调参数=" + JSON.toJSONString(params));

    return vipService.alipaySignCallback(params);
}

@Transactional
@Override
public String alipaySignCallback(Map<String, String> params) {

    if (!params.containsKey("external_agreement_no") || !params.containsKey("status") || !params.containsKey("agreement_no")) {
        log.error("支付宝周期扣款回调,无效请求,必需字段不存在,params=" + JSON.toJSONString(params));
        return "fail";
    }

    String externalAgreementNo = params.get("external_agreement_no");
    String status = params.get("status");
    String agreementNo = params.get("agreement_no");

    // 插入回调记录
    AlipaySignCallback alipaySignCallback = new AlipaySignCallback();
    alipaySignCallback.setExternalAgreementNo(externalAgreementNo);
    alipaySignCallback.setCallbackJson(JSON.toJSONString(params));
    alipaySignCallback.setCallbackStatus(status);
    alipaySignCallbackMapper.insert(alipaySignCallback);

    // 查询用户订阅记录
    CustomerPeriodSubscribe customerPeriodSubscribe = customerPeriodSubscribeMapper.findByContractNo(externalAgreementNo);
    if (CommonUtils.isNullOrEmpty(customerPeriodSubscribe)) {
        log.error("支付宝周期扣款回调,该签约号有误, params=" + JSON.toJSONString(params));
        return "fail";
    }

    // 如果是签约
    if (status.equals("NORMAL")) {

        // 如果签约状态不是签约中
        if (customerPeriodSubscribe.getContractStatus() != 1) {
                log.error("支付宝周期扣款回调,用户订阅记录签约状态不是签约中, params=" + JSON.toJSONString(params));
                 return "fail";
        }

        // 更新用户订阅状态
        customerPeriodSubscribe.setContractStatus(2);
        customerPeriodSubscribe.setContractTime(DateUtil.date());
        customerPeriodSubscribe.setAgreementNo(agreementNo);
        customerPeriodSubscribeMapper.updateById(customerPeriodSubscribe);
    }

    // 如果是解约
    if (status.equals("UNSIGN")) {

        // 如果签约状态不是签约中
        if (customerPeriodSubscribe.getContractStatus() != 2) {
            log.error("支付宝周期扣款回调,用户订阅记录签约状态不是订阅中,解约失败, params=" + JSON.toJSONString(params));
            return "fail";
        }

        customerPeriodSubscribe.setContractStatus(-1);
        customerPeriodSubscribe.setCancelTime(DateUtil.date());
        customerPeriodSubscribeMapper.updateById(customerPeriodSubscribe);
    }

    // 更新回调记录状态
    alipaySignCallback.setStatus(1);
    alipaySignCallbackMapper.updateById(alipaySignCallback);

    return "success";
}

定时扣款逻辑

@Override
public void batchAlipayPeriodPay(List<CustomerPeriodSubscribe> list) throws ParseException {

    List<CustomerPeriodSubscribe> updatePeriodSubscribeList = new ArrayList<>();

    for (CustomerPeriodSubscribe item : list) {

        // 判断今日是否已执行过扣款
        CustomerPeriodPayLog customerPeriodPayLog = customerPeriodPayLogMapper.findExecuteData(
                item.getCustomerId(), item.getId(), DateUtils.getCurrentDate());
        if (!CommonUtils.isNullOrEmpty(customerPeriodPayLog)) {
            log.info("用户ID:{}, periodSubscribeId: {}, 今日已执行过扣款,跳过", item.getCustomerId(), item.getId());
            continue;
        }

        // 获取应用参数
        Application application = commonService.getRedisApplicationByAppId(item.getAppId());

        // 获取会员价格配置
        VipConfig vipConfig = vipConfigMapper.findById(item.getVipConfigId());
        if (CommonUtils.isNullOrEmpty(vipConfig)) {
            continue;
        }

        // 查询该用户
        Customer customer = customerService.findById(item.getCustomerId());
        if (CommonUtils.isNullOrEmpty(customer)) {
            log.info("用户ID:{} 不存在,不执行扣款", item.getCustomerId());
            continue;
        }

        // 如果执行扣款是VIP会员
        if (ParentVipTypeEnum.vip.getType().equals(vipConfig.getParentType().intValue())) {

            // 如果已经是终身vip了
            if (VipTypeEnum.lifelong.getType().equals(customer.getVipType())) {
                log.info("用户ID:{} 已经是终身会员,不执行周期扣款", item.getCustomerId());
                continue;
            }
        } else {

            if (VipTypeEnum.adVipLifelong.getType().equals(customer.getAdVipType())) {
                log.info("用户ID:{} 已经是终身免广告会员,不执行周期扣款", item.getCustomerId());
                continue;
            }
        }

        // 创建支付记录和vip记录
        BeforeBuyVipBo beforeBuyVipBo = vipService.beforeBuyVip(item.getCustomerId(),
                vipConfig.getParentType().intValue(), vipConfig.getType(),
                item.getChannel(), item.getVersion(), PayTypeEnum.alipay, item.getOaid(), true);

        String parentVipTypeDesc = ParentVipTypeEnum.getDesc(vipConfig.getParentType().intValue());
        String subject = item.getChannel() + "-" + item.getVersion() + "-" + item.getCustomerId() + "-"
                + parentVipTypeDesc + "-" + vipConfig.getTitle() + "-" + application.getAppName() + "自动续费会员支付";

        // 调起自动扣款
        String tradePayResp = this.alipayTradePay(application.getAlipayAppId(), application.getAlipayMchPrivateKey(),
                application.getAlipayPublicKey(), item.getAgreementNo(), item.getPayAmount().toString(), subject,
                beforeBuyVipBo.getPay().getOutTradeNo());

        JSONObject payRespMap = null;
        try {
            payRespMap = JSON.parseObject(tradePayResp);
        } catch (Throwable ignored) {}

        Integer payStatus = -1;

        if (payRespMap != null) {
            // 如果接口调用成功
            if (payRespMap.getString("code").equals("10000")) {
                payStatus = 1;
            }
        }

        // 写入周期扣日志
        customerPeriodPayLog = new CustomerPeriodPayLog();
        customerPeriodPayLog.setAppId(application.getId());
        customerPeriodPayLog.setCustomerId(item.getCustomerId());
        customerPeriodPayLog.setPeriodSubscribeId(item.getId());
        customerPeriodPayLog.setPayId(beforeBuyVipBo.getPay().getId());
        customerPeriodPayLog.setSubject(subject);
        customerPeriodPayLog.setRespJson(tradePayResp);
        customerPeriodPayLog.setPayStatus(payStatus);
        customerPeriodPayLog.setDate(DateUtil.date().toDateStr());
        customerPeriodPayLogMapper.insert(customerPeriodPayLog);

        // 如果扣款成功
        if (payStatus.equals(1)) {
            CustomerPeriodSubscribe updatePeriodSubscribeData = new CustomerPeriodSubscribe();
            updatePeriodSubscribeData.setId(item.getId());
            // 下次系统扣款日
            updatePeriodSubscribeData.setNextPayDate(
                    DateUtil.format(
                            DateUtil.offsetMonth(
                                    DateUtil.parseDate(item.getNextPayDate()), 1), "yyyy-MM-dd"));
            updatePeriodSubscribeData.setContractNextPayDate(DateUtil.format(
                    DateUtil.offsetMonth(
                            DateUtil.parseDate(item.getContractNextPayDate()), 1), "yyyy-MM-dd"));
            updatePeriodSubscribeList.add(updatePeriodSubscribeData);
        }
    }

    if (updatePeriodSubscribeList.size() > 0) {
        customerPeriodSubscribeService.updateBatchById(updatePeriodSubscribeList);
    }

}

private String alipayTradePay(String alipayAppId, String alipayMchPrivateKey, String alipayPublicKey,
                            String agreementNo, String totalAmount, String subject, String outTradeNo) {

    Config config = new Config();
    config.protocol = "https";
    config.gatewayHost = "openapi.alipay.com";
    config.signType = "RSA2";
    config.appId = alipayAppId;
    config.merchantPrivateKey = alipayMchPrivateKey;
    config.alipayPublicKey = alipayPublicKey;
    // 可设置异步通知接收服务地址(可选)
    config.notifyUrl = alipayCallbackUrl;
    // 设置参数
    Factory.setOptions(config);

    AlipayOpenApiGenericResponse response;

    try {

        Map<String, String> agreementSignParams = new HashMap<>();
        agreementSignParams.put("agreement_no", agreementNo);

        Map<String, Object> bizContents = new HashMap<>();
        bizContents.put("product_code", "CYCLE_PAY_AUTH");
        bizContents.put("agreement_params", agreementSignParams);
        bizContents.put("out_trade_no", outTradeNo);
        bizContents.put("total_amount", totalAmount);
        bizContents.put("subject", subject);

        // 发起API调用
        response = Factory.Util.Generic().execute("alipay.trade.pay", new HashMap<>(), bizContents);

        // 处理响应或异常
        if (!ResponseChecker.success(response)) {
            System.out.println(response.toMap());
            log.error("支付宝调用失败");
            return JSON.toJSONString(response.toMap());
        }

    } catch (Throwable e) {
        log.error("支付宝调用失败,原因:" + e.getMessage());
        return "支付宝调用失败,原因:" + e.getMessage();
    }

    return JSON.toJSONString(response.toMap());
}

解除签约回调

去支付宝的开放后台设置设置应用网关。用户解除签约的时候,是会回调到这个地址的

支付宝周期扣款逻辑梳理和代码流程设计

解除签约回调代码

    /**
     * 支付宝应用网关回调
     *
     * @param request
     * @return
     * @throws Exception
     */
    @PostMapping(value = "/alipayGatewayCallback")
    public String alipayGatewayCallback(HttpServletRequest request) throws Exception {

        // 目前只有周期扣款会回调这里
        return this.alipaySignNotify(request);

    }

还可优化

customer_period_pay_log 表里会记录本次自动扣款的状态,可能会有用户余额不足而扣款失败的情况,可以在加一个定时器来拉取这张表里扣款失败的重新尝试

参考链接

支付宝周期扣款(支付后签约)业务功能总结(php+golang)

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!