跨境支付pingpong订阅接入设计方案

背景

我们公司是做海外视频App服务的,所以会员是我们业务线最重要的收入之一了。 最近接入新的h5三方支付,特来总结一下,总体来说三方支付接入不难。我们之前已经接入了原生的 Google pay、Apple Pay、h5三方支付接入了Paypal、payermax。

那既然有了原生的 Google Pay和Apple pay,为啥还需要h5支付呢?

其实是为了包下架之后,原生支付不能使用了,h5支付作为备用支付方式工作。

paypal 我们没有接入订阅功能,所以我简单说一下 payermax和新接入的pingpong支付的区别,其实最大的区别就在于订阅周期的维护。

payermax 在首期签约之后,后续扣款周期以及流程都是由payermax严格控制的,每期扣款结果将以回调商户接口的方式通知商户扣款情况,商户根据扣款结果做对应处理。

pingpong 在首期签约成功之后,是不维护扣款周期的,也不会主动发起扣款,整个续订过程都由商户控制(简单来说就是商户想什么时候扣款就什么时候扣款,当然前提是用户首期签约扣款成功)。

需求分析

  1. 采用Hosted托管付款页面模式,即直接跳转pingpong信用卡支付页面(跳转pingpong收银台,收银台pingpong支持简单定制);

  2. 用户首次订阅,订阅号中,需要重点维护用户ID,首月优惠价格,次月价格,并固化价格(当修改价格后,之前订阅的用户不受影响),订阅周期(首次为0,下一期记为1),订阅状态;

    特殊情况

    用户第一次订阅后,订阅状态为"订阅成功",1期续订扣款失败,订阅状态为"订阅成功",备注"1轮扣款失败" 在这种情况下,用户手动再次订阅,原订阅状态变更为"订阅取消(重订阅)",然后创建新的订阅;

  3. 用户到期时间前24小时发起扣款, 当扣款成功以后,生成对应订单,订阅期数+1;

  4. 若扣款失败,则在失败后3,6,12小时再次发起扣款申请,直到成功;

  5. 若在到期前扣款一直没有成功,则不再尝试扣款,订阅号状态标记为"本期扣款失败"(但是订阅仍成功),下一个月继续扣款,若连续3期扣款失败,则后续不再发起扣款申请(订阅中止);

  6. 若扣款失败后,用户自己发起同一个订阅,则创建新的订阅号,之前的订阅号状态修改为"重订阅取消";

  7. 允许用户存在多个订阅计划的行为,但不允许同一个订阅计划重复订阅,即已经订阅了连续包月,则不允许再次订阅连续包月,若已经订阅了连续包月,点击支付时,toast提示"您已订阅此计划,请勿重复订阅";

pingpong 支付订阅流程

  • Pingpong checkout 交互流程

一次性支付流程很简单:

  1. 用户发起购买 -> 商户平台创建订单 -> 提交pingpong后台支付申请 -> 获取pingpong收银台地址 -> 商户h5打开pingpong收银台,后续操作都在pingpong系统上了;

  2. 商户接受pingpong服务器通知,根据通知扣款结果做后续处理;

所以,这里我们只需要实现两个接口即可,一个是下单接口,二是接收回调接口;

  • 订阅流程 从流程图中可以看出,订阅的首期签约扣款和一次性购买流程基本一致,只是在向pingpong提交订单申请时增加点字段而已;

    计划扣款是重点,不过也是向pingpong提交订单申请,值得注意的是,pingpong的签约号(一个订阅周期的唯一标识)也是由商户这边自己维护的,签约号是首期扣款商户提交的订单号(merchantTransactionId),后续的每一起扣款需要把这个订单号带上,字段是 primaryMerchantTransactionId

pingpong订阅流程

我们这边的订阅设计方案是简单的定时任务触发,整个设计方案中会用到延迟队列,延迟队列我在之前博文中介绍过: 基于 Redisson 和 Kafka 的延迟队列设计方案为了方便开发,我打算实现一个Redis 工具集

而支付订单相关的表设计,在之前介绍接入 Google pay 时给出过: 海外支付(GooglePay)服务端设计方案Google 支付订阅商品服务端设计方案Google 支付订阅补坑之路

表设计

  • 订单表
mysql 复制代码
CREATE TABLE `vip_order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_sn` varchar(100) NOT NULL COMMENT '订单编号',
  `third_order_sn` varchar(100) DEFAULT NULL COMMENT '第三方订单编号',
  `origin_order_sn` varchar(100) DEFAULT NULL COMMENT '源订单号,发起退款时关联的订单',
  `type` tinyint(1) DEFAULT NULL COMMENT '订单类型 0 一次性购买 1 周期扣款订单',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
  `goods_id` bigint(20) DEFAULT NULL COMMENT '商品id',
  `price` bigint(20) DEFAULT '0' COMMENT '订单金额',
  `distribution_channel` varchar(20) DEFAULT NULL COMMENT '分销渠道',
  `payment_method` varchar(50) DEFAULT NULL COMMENT '付款方式:0 Google pay ',
  `status` tinyint(1) DEFAULT NULL COMMENT '订单状态:0:初始化 1:已完成 2 已经取消 3 已退款',
  `deliver_status` tinyint(1) DEFAULT NULL COMMENT '发货状态:0:发货失败 1:发货成功',
  `complete_time` datetime DEFAULT NULL COMMENT '订单完成时间',
  `refund_time` datetime DEFAULT NULL COMMENT '退款时间',
  `pay_time` datetime DEFAULT NULL COMMENT '付款时间',
  `device_id` varchar(255) DEFAULT NULL COMMENT '设备ID',
  `ip` varchar(100) DEFAULT NULL COMMENT 'IP地址',
  `country` varchar(20) DEFAULT NULL COMMENT '国家',
  `origin_info` text COMMENT '第三方支付信息',
  `remark` varchar(200) DEFAULT NULL COMMENT '备注',
  `support_refund` tinyint(1) DEFAULT NULL COMMENT '是否支持退款',
  `refund_reason` varchar(200) DEFAULT NULL COMMENT '退款原因',
  `goods_info` text COMMENT '商品信息',
  `package_name` varchar(500) DEFAULT NULL COMMENT '包名',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `abnormal_reason` varchar(500) DEFAULT NULL COMMENT '订单异常类型原因',
  `abnormal_status` tinyint(1) DEFAULT NULL COMMENT '异常处理状态 0 未处理 1 处理中 2 已处理',
  `abnormal` varchar(50) DEFAULT '0' COMMENT '订单异常类型,0  正常订; 1  价格异常; 2. 多笔订单; 3. 已支付未发货; 4. 发放天数异常; 5. 扣款周期异常',
  `local_currency` varchar(25) DEFAULT NULL COMMENT '本地价格单位',
  `local_price` varchar(255) DEFAULT NULL COMMENT '本地价格',
  PRIMARY KEY (`id`),
  KEY `index_third_order_sn` (`third_order_sn`) USING BTREE,
  KEY `index_user_id` (`user_id`) USING BTREE,
  KEY `index_order_sn` (`order_sn`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='会员订单表';
  • 订阅表
mysql 复制代码
CREATE TABLE `vip_renewal_order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_sn` varchar(100) NOT NULL COMMENT '订单编号',
  `latest_order_sn` varchar(100) NOT NULL COMMENT '上次订单号',
  `subscription_index` int(11) DEFAULT NULL COMMENT '扣款期数',
  `sign_no` varchar(255) DEFAULT NULL COMMENT '订阅号',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
  `goods_id` bigint(20) DEFAULT NULL COMMENT '商品id',
  `goods_info` varchar(1000) DEFAULT NULL COMMENT '固化商品信息',
  `first_period_price` int(11) DEFAULT NULL COMMENT '首期价格',
  `price` int(11) DEFAULT '0' COMMENT '订单金额',
  `sign_channel` varchar(20) DEFAULT NULL COMMENT '签约渠道(支付渠道):google pay 、 paypal',
  `sign_status` tinyint(1) DEFAULT NULL COMMENT '订阅状态,0-订阅处理中 1-订阅成功 2-订阅失败 3-订阅取消 4-订阅失效',
  `sign_reason` varchar(1000) DEFAULT NULL COMMENT '状态原因',
  `deduct_status` tinyint(1) DEFAULT NULL COMMENT '本期扣款状态',
  `renewal_content` text COMMENT '第三方续订内容',
  `start_order_time` datetime DEFAULT NULL COMMENT '订单第一次订阅时间',
  `latest_order_time` datetime DEFAULT NULL COMMENT '上一次订单自动续约时间',
  `next_order_time` datetime DEFAULT NULL COMMENT '理论上下次扣款时间',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `linked_purchase_token` varchar(1000) DEFAULT NULL COMMENT '解决google重复订阅的关联token',
  `purchase_token` varchar(1000) DEFAULT NULL COMMENT '本次token',
  `user_info` varchar(500) DEFAULT NULL COMMENT '固化用户信息',
  PRIMARY KEY (`id`),
  KEY `index_order_sn` (`order_sn`) USING BTREE,
  KEY `index_latest_order_sn` (`latest_order_sn`) USING BTREE,
  KEY `index_user_id` (`user_id`) USING BTREE,
  KEY `index_purchase_token` (`purchase_token`(191)) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='会员自动续订表';
  • pingpong订阅日志表
mysql 复制代码
CREATE TABLE `pingpong_vip_renewal_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `batch_no` varchar(100) NOT NULL COMMENT '批处理ID',
  `subscription_index` int(11) DEFAULT NULL COMMENT '扣款期数',
  `sign_no` varchar(255) NOT NULL COMMENT '订阅号',
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `goods_id` bigint(20) NOT NULL COMMENT '商品id',
  `price` int(11) DEFAULT '0' COMMENT '订单金额',
  `status_reason` varchar(500) DEFAULT NULL COMMENT '状态原因',
  `deduct_status` tinyint(1) DEFAULT NULL COMMENT '本期扣款状态',
  `retry_nums` int(11) DEFAULT NULL COMMENT '本期扣款重试第几次',
  `request_content` text COMMENT '请求内容',
  `renewal_content` text COMMENT '第三方续订内容',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `order_sn` varchar(255) NOT NULL DEFAULT '' COMMENT '订单号',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `index_user_id` (`user_id`) USING BTREE,
  KEY `index_sign_no` (`sign_no`(191)) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='pingpong会员订阅记录';

重点代码

创建订单

java 复制代码
@ApiOperation(value = "创建 pingpong vip 订单")
@PostMapping("/pingpong/prePay")
@WriteLog
public ResultResponse<PingPongVipOrderVO> createVipOrderByPingPong(@RequestBody @Valid VipOrderRequest request) throws UserClientException {
    if(!PayMethodEnum.PING_PONG_CARD_H5.getCode().equalsIgnoreCase(request.getPayMethod())){
        throw new UserClientException("pay method not support");
    }
    Long userId = httpServletRequestHelper.getCurrentUserId();
    String lockKey = String.format(Constant.PING_PONG_CREATE_ORDER_LOCK, userId);
    RLock lock = redissonClient.getLock(lockKey);
    try {
        if(lock.isLocked() || !lock.tryLock()){
            log.info("用户:{} 创建 pingpong 订单,未获取到锁,可能存在重复提交行为", userId);
            return ResultResponse.success();
        }
        return ResultResponse.success(pingPongVipService.createVipOrder(request));
    } finally {
        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
java 复制代码
/**
 *  创建会员订单
 * @param request
 * @return
 * @throws UserClientException
 */
@Override
public PingPongVipOrderVO createVipOrder(VipOrderRequest request) throws UserClientException {
    String countryIsoCode = httpServletRequestHelper.getCountryIsoCode();
    String language = httpServletRequestHelper.getLanguage();
    Long currentUserId = httpServletRequestHelper.getCurrentUserId();
    String deviceId = httpServletRequestHelper.getDeviceId();

    vipOrderService.auditModeCheckSupportBuy(countryIsoCode, currentUserId, deviceId);
    H5MarketVipGoodsVo marketVipGoodsVo = commodityFeign.findMarketGoodsH5(request.getMarketId()).getData();
    if(marketVipGoodsVo == null){
        log.error("上架市场:{},国家码:{} 未能找到上架商品", request.getMarketId(), countryIsoCode);
        throw new UserClientException(Constant.VipOrderExceptionConstant.GOODS_NOT_FOUND);
    }
    log.info("通过marketId:{}, countryIsoCode:{} 获取商品:{}", request.getMarketId(), countryIsoCode, JSON.toJSONString(marketVipGoodsVo));

    H5VipGoodsPriceDTO goodsPrice = vipOrderService.checkPriceAndGet(request, currentUserId, deviceId, marketVipGoodsVo);

    PingPongVipOrderVO pingPongVipOrderVO;

    if(marketVipGoodsVo.isSubscribed()){
        // 订阅商品逻辑
        pingPongVipOrderVO = preCreateSubscribedOrder(request, countryIsoCode, currentUserId, marketVipGoodsVo, goodsPrice);
    } else {
        // 一次性购买商品逻辑
        // 创建本地订单
        pingPongVipOrderVO = preCreateOneTimePurchaseOrder(request, countryIsoCode, language, marketVipGoodsVo, goodsPrice);
    }
    return pingPongVipOrderVO;
}
java 复制代码
/**
 *   预创建订阅订单
 * @param request
 * @param countryIsoCode
 * @param currentUserId
 * @param marketVipGoodsVo
 * @param goodsPrice
 * @return
 * @throws UserClientException
 */
private PingPongVipOrderVO preCreateSubscribedOrder(VipOrderRequest request, String countryIsoCode, Long currentUserId,
                                                    H5MarketVipGoodsVo marketVipGoodsVo, H5VipGoodsPriceDTO goodsPrice) throws UserClientException {

    // 检查用户是否重复订阅
    checkUserRepeatRenewal(request, currentUserId);

    // 预创建订单
    String orderSn = orderSnGenerateHelper.generateVipOrderSn(PayMethodEnum.PING_PONG_CARD_H5);
    String language = httpServletRequestHelper.getLanguage();

    // pingpong 发起订阅请求
    BigDecimal decimal = new BigDecimal(String.valueOf(goodsPrice.getLocalPrice())).divide(new BigDecimal("100"), 2, RoundingMode.DOWN);
    PingPongPreOrderDto orderDto = PingPongPreOrderDto.builder()
            .sku(String.valueOf(marketVipGoodsVo.getGoodsId()))
            .currency(goodsPrice.getCurrency())
            .amount(decimal.toPlainString())
            .goodsName(marketVipGoodsVo.getGoodsNameDefaultEn(language))
            .merchantTransactionId(orderSn)
            .shopperIP(IPUtil.getRemoteIp())
            .tradeCountry(countryIsoCode)
            .language(language)
            .subscribed(marketVipGoodsVo.isSubscribed())
            .build();

    PingPongCommonRequest prePayRequest = PingPongCommonRequest.buildPay(pingPongProperties, orderDto);
    PingPongResultVo pingPongResultVo = pingPongServiceFeign.prePay(prePayRequest);
    log.info("发起pingpong支付申请, param:{}, result:{}", JSON.toJSONString(prePayRequest), JSON.toJSONString(pingPongResultVo));

    String renewalContent = JSON.toJSONString(pingPongResultVo);

    if(!pingPongResultVo.resultIsSuccess()){
        // 下单失败
        log.info("pingpong 发起订阅请求失败, orderSn:{}", orderSn);
        throw new ThirdPartyServiceCallException("pingpong 发起订阅请求失败");
    }

    VipOrder vipOrder = vipOrderService.createFirstVipOrderByPingPong(request, marketVipGoodsVo, VipOrderTypeEnum.SUBSCRIPTION,
            orderSn, goodsPrice, pingPongResultVo.parserPayBizContent().getTransactionId(), renewalContent);

    String subscriptionNo = pingPongResultVo.parserPayBizContent().getMerchantTransactionId();

    // 是否有初始化的订阅 如果有就复用订阅
    VipRenewalOrder waitingRenewalOrder = renewalOrderRepository.findByUserAndGoodsAndSignChannelAndSignStatus(currentUserId, request.getGoodsId(), RenewalSignChannelEnum.PING_PONG.getCode(), RenewalSignStatusEnum.WAITING.getCode());
    if(waitingRenewalOrder != null){
        // 复用订阅
        vipOrderService.reuseWaitingRenewalOrder(waitingRenewalOrder, vipOrder, renewalContent, marketVipGoodsVo.getRenewalPrice(), subscriptionNo);
    } else {
        // 创建订阅
        waitingRenewalOrder = vipOrderService.createNewSubscribeOrder(vipOrder, subscriptionNo,
                orderSnGenerateHelper.generateVipRenewalOrderSn(PayMethodEnum.PING_PONG_CARD_H5), RenewalSignStatusEnum.WAITING,
                RenewalSignChannelEnum.PING_PONG, renewalContent, marketVipGoodsVo.getRenewalPrice(), DeductStatusEnum.WAIT_DEDUCTED);
    }

    // 第一次订阅Log
    PingpongVipRenewalLog renewalLog = PingpongVipRenewalLog.create(Constant.PingPongPay.generateBatchId(new Date()), waitingRenewalOrder);
    renewalLog.setRequestContent(JSON.toJSONString(prePayRequest));
    pingPongVipRenewalLogService.saveNewTransaction(renewalLog);

    PingPongVipOrderVO pingPongVipOrderVO = new PingPongVipOrderVO(orderSn, pingPongResultVo.parserPayBizContent().getPaymentUrl(),
            pingPongResultVo.parserPayBizContent().getTransactionId());
    return pingPongVipOrderVO;
}
java 复制代码
/**
 *  检查用户是否重复订阅
 * @param request
 * @param currentUserId
 * @throws UserClientException
 */
private void checkUserRepeatRenewal(VipOrderRequest request, Long currentUserId) throws UserClientException {
    // 检查用户是否正常订阅了该商品
    VipRenewalOrder successRenewalOrder = renewalOrderRepository.findByUserAndGoodsAndSignChannelAndSignStatus(currentUserId, request.getGoodsId(), RenewalSignChannelEnum.PING_PONG.getCode(), RenewalSignStatusEnum.SUCCESS.getCode());

    if(successRenewalOrder != null){

        // 如果本期扣款 成功 则提示 有成功的订阅 如果本期扣款失败,则取消原订阅,生成新订阅
        if(RenewalSignStatusEnum.SUCCESS.getCode().equals(successRenewalOrder.getSignStatus())
                && DeductStatusEnum.repeatSubscription(successRenewalOrder.getDeductStatus())){
            log.error("出现重复订阅,根据用户:{} 和商品:{} 找到签约成功的续订单", currentUserId, request.getGoodsId());
            throw new UserClientException(Constant.VipOrderExceptionConstant.VIP_SUBSCRIBE_FAIL_REPEAT_SUBSCRIPTION);
        }
        // 取消原来订单
        successRenewalOrder.setSignStatus(RenewalSignStatusEnum.CANCEL.getCode());
        successRenewalOrder.setStatusReason("用户重订阅取消");
        renewalOrderRepository.save(successRenewalOrder);
    }
}

接收支付回调用

java 复制代码
/**
 *  交易 通知
 * @param pingPongResultVo
 * @param callback
 */

@Override
@Transactional(rollbackFor = Exception.class)
public void pingPongPayCallback(PingPongResultVo pingPongResultVo, PingPongBizContentCallback callback) {

    // 检查签名
    checkSign(pingPongResultVo);

    // 检查订单
    VipOrder vipOrder = vipOrderService.checkPayOrder(callback.getMerchantTransactionId());
    String renewalContent = JSON.toJSONString(pingPongResultVo);
    if(StringUtils.isEmpty(vipOrder.getThirdOrderSn())){
        // 补偿三方交易流水
        log.info("订单:{} 三方交易流水null, 补偿三方交易流水:{}", vipOrder.getOrderSn(), callback.getTransactionId());
        vipOrder.setThirdOrderSn(callback.getTransactionId());
        vipOrderService.updateOrderData(vipOrder);
    }

    if(!callback.paySuccess()){
        log.info("订单:{} 支付失败,三方回执:{}", callback.getMerchantTransactionId(), renewalContent);
        //订单处理失败
        vipOrderService.updateOrderFail(vipOrder.getId(), renewalContent);

        // 如果是续订,不是第一期扣款的话 扣款失败需要重试
        if(vipOrder.subscriptionOrder()){
            vipRenewalOrderService.updatePingPongRenewalFail(callback.getMerchantTransactionId(), renewalContent);
        }
        return;
    }

    // 更新订单为 已支付
    vipOrderService.updateOrderPaid(vipOrder.getId(), renewalContent);

    if(vipOrder.subscriptionOrder()){
        // 更新订阅成功数据
        vipRenewalOrderService.updatePingPongRenewalSuccess(callback.getMerchantTransactionId(), vipOrder, renewalContent);
    }

    // 通知发放会员
    boolean grantUserVipSuccess = vipOrderService.grantUserVip(vipOrder);
    if(grantUserVipSuccess){
        vipOrderService.updateOrderDeliverSuccess(vipOrder);
    }
}
java 复制代码
// 订阅失败处理
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void updatePingPongRenewalFail(String orderSn, String renewalContent) {
    // 订阅订单
    VipRenewalOrder vipRenewalOrder = renewalOrderRepository.findByLatestOrderSn(orderSn);
    if(vipRenewalOrder == null){
        return;
    }
    // 不是第一期 发送延迟消息 重试
    if(vipRenewalOrder.getSubscriptionIndex() != 0){
        // 发送延迟消息
        // 更新订阅
        vipRenewalOrder.setDeductStatus(DeductStatusEnum.DEDUCTION_RETRY.getCode());
        vipRenewalOrder.setStatusReason("本期扣款失败,正在扣款重试中...");

        // 更新订阅log
        PingpongVipRenewalLog vipRenewalLog = pingpongVipRenewalLogRepository.findByOrderSnAndSignNo(orderSn, vipRenewalOrder.getSignNo());
        if(vipRenewalLog != null){
            vipRenewalLog.setDeductStatus(DeductStatusEnum.DEDUCTED_FAIL.getCode());
            vipRenewalLog.setStatusReason("本期扣款失败");
            vipRenewalLog.setRenewalContent(renewalContent);
            pingpongVipRenewalLogRepository.save(vipRenewalLog);
        }

        // 发送延迟消息 重试
        Integer retryNums = Optional.ofNullable(vipRenewalLog.getRetryNums()).orElse(0) + 1;
        if(retryNums > pingPongProperties.getMaxRetryNums()){
            log.info("ping pong 订阅:{} 达到最大重试次数, 更新订阅失败", vipRenewalOrder.getSignNo());
            // 检查最近几期都失败
            checkPingPongRecentlyFailed(vipRenewalOrder, vipRenewalLog.getSubscriptionIndex());
        } else {
            TimeUnit timeUnit = pingPongProperties.isOpenTest() ? TimeUnit.SECONDS : TimeUnit.HOURS;
            delayQueueMessageProducer.sendMessage(MessageEvent.PING_PONG_RENEWAL_FAIL_RETRY_TOPIC, vipRenewalOrder.getSignNo(),
                    retryNums * pingPongProperties.getRetryIntervalRatio(), timeUnit);
        }
    } else {
        // 首期就失败
        vipRenewalOrder.setDeductStatus(DeductStatusEnum.DEDUCTED_FAIL.getCode());
        vipRenewalOrder.setSignStatus(RenewalSignStatusEnum.FAIL.getCode());
        vipRenewalOrder.setStatusReason("首期扣款失败,签约失败");
    }
    vipRenewalOrder.setRenewalContent(renewalContent);
    renewalOrderRepository.save(vipRenewalOrder);
}

计划扣款

java 复制代码
/** job 触发
 *
 *  订阅周期扣款 创建扣款日志 ,发送 扣款任务到 kafka
 * @param date
 */
@Override
public void subscriptionDeduction(Date date) {

    String batchId = Constant.PingPongPay.generateBatchId(date);

    // 获取 到期时间前24小时 订阅状态是订阅中的 订阅
    int page = 0;
    Page<VipRenewalOrder> renewalOrderPage;
    do {
        PageRequest pageRequest = PageRequest.of(page, 1000, new Sort(Sort.Direction.DESC, "id"));
        renewalOrderPage = renewalOrderRepository.findByTimePage(pingPongProperties.getBeforeExpirationMinutes(), pageRequest);
        page ++;

        // 写入续订日志表
        List<VipRenewalOrder> content = renewalOrderPage.getContent();

        if(!CollectionUtils.isEmpty(content)){
            // 过滤状态不是 扣款处理中和扣款重试的订阅
            List<PingpongVipRenewalLog> renewalLogs = content.stream()
                    .filter(item -> !DeductStatusEnum.DEDUCTION_RETRY.getCode().equals(item.getDeductStatus()) &&
                    !DeductStatusEnum.DEDUCTED_ING.getCode().equals(item.getDeductStatus()))
                    .map(item -> PingpongVipRenewalLog.create(batchId, item)).collect(Collectors.toList());
            pingPongVipRenewalLogService.saveAllNewTransaction(renewalLogs);
            renewalLogs.stream().forEach(item -> messageProducer.sendSimpleMessage(MessageEvent.PING_PONG_RENEWAL_LOG_TOPIC , item.getId()));
        }
    } while (renewalOrderPage.hasNext());
}

kafka消费需要扣款的续订任务

java 复制代码
@KafkaListener(topics = { MessageEvent.PING_PONG_RENEWAL_LOG_TOPIC }, groupId = KAFKA_GROUP)
public void listenerPingPongPayPlanDeduction(String logId) throws UserClientException {
    log.info("topic : {}, 监听pingpong 订阅任务 , logId : {} 开始处理订阅", MessageEvent.PING_PONG_RENEWAL_LOG_TOPIC, logId);

    PingpongVipRenewalLog renewalLog = logRepository.findById(Long.valueOf(logId)).orElse(null);
    if(renewalLog == null){
        log.info("PingpongVipRenewalLog logId: {} 不存在", logId);
        return;
    }
    RLock lock = null;
    try {
        // 锁住签约号,多个任务对同于一个签约处理,需要排队
        lock = redisUtils.lock(String.format(Constant.PING_PONG_VIP_PAY_PLAN_DEDUCTION_LOCK, renewalLog.getSignNo()));
        // 开始处理 计划扣款
        pingPongVipService.prePlanDeduction(renewalLog);
    } finally {
        if (lock != null  && lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
java 复制代码
/**
 *  开始处理计划扣款 后续扣款
 * @param renewalLog
 */
@Override
public void prePlanDeduction(PingpongVipRenewalLog renewalLog) {
    // 检查订阅日志是否已经处理
    if(renewalLog.processed()){
        log.info("PingpongVipRenewalLog:{} processed", renewalLog.getId());
        return;
    }

    // 获取 订阅
    VipRenewalOrder renewalOrder = renewalOrderRepository.findBySignNoAndSignChannel(renewalLog.getSignNo(), RenewalSignChannelEnum.PING_PONG.getCode());
    // 检查订阅是否需要处理
    if(!renewalOrder.needProcess()){
        log.info("VipRenewalOrder :{} not need process", JSON.toJSONString(renewalOrder));
        return;
    }

    log.info("----开始处理扣款---:{}", renewalLog.getId());
    // 开始处理 扣款
    this.doPlanDeduction(renewalLog, renewalOrder, false);

}
java 复制代码
/**
 *  处理扣款
 * @param renewalLog
 * @param renewalOrder
 */
private void doPlanDeduction(PingpongVipRenewalLog renewalLog, VipRenewalOrder renewalOrder, boolean retry) {
    // 固化商品
    H5MarketVipGoodsVo marketVipGoodsVo = JSON.parseObject(renewalOrder.getGoodsInfo(), H5MarketVipGoodsVo.class);

    // 固化的用户
    VipOrderUserInfoDTO userInfoDTO = JSON.parseObject(renewalOrder.getUserInfo(), VipOrderUserInfoDTO.class);
    userInfoDTO.setUserId(renewalOrder.getUserId());

    // 创建订单号
    String orderSn = orderSnGenerateHelper.generateVipOrderSn(PayMethodEnum.PING_PONG_CARD_H5);

    // 创建请求参数
    BigDecimal decimal = new BigDecimal(String.valueOf(marketVipGoodsVo.getLocalRenewPrice())).divide(new BigDecimal("100"), 2, RoundingMode.DOWN);
    PingPongPreOrderDto orderDto = PingPongPreOrderDto.builder()
            .sku(String.valueOf(marketVipGoodsVo.getGoodsId()))
            .currency(marketVipGoodsVo.getLocalUnit())
            .amount(decimal.toString())
            .goodsName(marketVipGoodsVo.getGoodsNameDefaultEn(userInfoDTO.getLanguage()))
            .merchantTransactionId(orderSn)
            .tradeCountry(userInfoDTO.getCountryIsoCode())
            .language(userInfoDTO.getLanguage())
            .primaryMerchantTransactionId(renewalOrder.getSignNo())
            .subscribed(marketVipGoodsVo.isSubscribed())
            .build();
    PingPongCommonRequest prePayRequest = PingPongCommonRequest.buildUnifiedPay(pingPongProperties, orderDto);

    // 修改订阅 , 订单状态本期处理中
    int subscriptionIndex = Optional.ofNullable(renewalOrder.getSubscriptionIndex()).orElse(0);
    if(!retry){
        subscriptionIndex = subscriptionIndex + 1;
    }

    // 创建订单
    VipOrder vipOrder = vipOrderService.createVipOrderAndUpdateOrderSnByPingPongRenew(userInfoDTO, marketVipGoodsVo, orderSn, renewalLog, renewalOrder, JSON.toJSONString(prePayRequest), subscriptionIndex);

    PingPongResultVo pingPongResultVo = pingPongServiceFeign.unifiedPay(prePayRequest);
    log.info("ping pong 发起计划扣款,请求:{},响应:{}", JSON.toJSONString(prePayRequest), JSON.toJSONString(pingPongResultVo));

    // 交易号,返回值
    vipOrder.setThirdOrderSn(pingPongResultVo.findPingPongTransactionId());
    vipOrderService.updateOrderData(vipOrder);

}

kafka监听失败重试

java 复制代码
@KafkaListener(topics = { MessageEvent.PING_PONG_RENEWAL_FAIL_RETRY_TOPIC }, groupId = KAFKA_GROUP)
public void listenerPingPongPayFailRetry(String renewalSignNo) throws UserClientException {
    log.info("topic : {}, 监听pingpong 扣款失败 , renewalSignNo : {} 准备重试", MessageEvent.PING_PONG_RENEWAL_FAIL_RETRY_TOPIC, renewalSignNo);
    RLock lock = null;
    try {
        // 锁住签约号,多个任务对同于一个签约处理,需要排队
        lock = redisUtils.lock(String.format(Constant.PING_PONG_VIP_PAY_PLAN_DEDUCTION_LOCK, renewalSignNo));

        // 开始处理 计划扣款重试
        pingPongVipService.payFailRetry(renewalSignNo);
    } finally {
        if (lock != null  && lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
scss 复制代码
/**
 *  扣款失败重试
 * @param renewalSignNo
 */
@Override
public void payFailRetry(String renewalSignNo) {

    VipRenewalOrder vipRenewalOrder = renewalOrderRepository.findBySignNoAndSignChannel(renewalSignNo, RenewalSignChannelEnum.PING_PONG.getCode());
    // 检查订阅是否需要处理
    if(!vipRenewalOrder.needProcess()){
        log.info("VipRenewalOrder :{} not need process", JSON.toJSONString(vipRenewalOrder));
        return;
    }

    // 获取上一次重试日志
    PingpongVipRenewalLog prevRenewalLog = vipRenewalLogRepository.findByOrderSnAndSignNo(vipRenewalOrder.getLatestOrderSn(), vipRenewalOrder.getSignNo());

    // 检查是否达到最大重试次数
    Integer retryNums = Optional.ofNullable(prevRenewalLog.getRetryNums()).orElse(0) + 1;
    if(retryNums > pingPongProperties.getMaxRetryNums()){
        log.info("ping pong 订阅:{} 达到最大重试次数, 更新订阅失败", vipRenewalOrder.getSignNo());
        // 检查最近几期都失败
        vipRenewalOrderService.checkPingPongRecentlyFailed(vipRenewalOrder, prevRenewalLog.getSubscriptionIndex());
        return;
    }

    // 开始重试
    // 创建重试日志
    PingpongVipRenewalLog renewalLog = PingpongVipRenewalLog.create(Constant.PingPongPay.generateBatchId(new Date()), vipRenewalOrder);
    renewalLog.setRetryNums(retryNums);
    pingPongVipRenewalLogService.saveNewTransaction(renewalLog);

    // 开始处理重试
    this.doPlanDeduction(renewalLog, vipRenewalOrder, true);
}
相关推荐
码农派大星。几秒前
Spring Boot 配置文件
java·spring boot·后端
顾北川_野7 分钟前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java
江深竹静,一苇以航10 分钟前
springboot3项目整合Mybatis-plus启动项目报错:Invalid bean definition with name ‘xxxMapper‘
java·spring boot
confiself25 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
Wlq041530 分钟前
J2EE平台
java·java-ee
XiaoLeisj37 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
杜杜的man40 分钟前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*41 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu43 分钟前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s44 分钟前
Golang--协程和管道
开发语言·后端·golang