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

2023年 10月 16日 113.5k 0

背景

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

那既然有了原生的 Google Pay和Apple pay,为啥还需要h5支付呢?
其实是为了包下架之后,原生支付不能使用了,h5支付作为备用支付方式工作。

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

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

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

需求分析

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

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

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

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

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

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

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

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

  • pingpong 支付订阅流程

    • Pingpong checkout 交互流程
      image.png

    一次性支付流程很简单:

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

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

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

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

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

    pingpong订阅流程

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

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

    表设计

    • 订单表
    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='会员订单表';
    
    • 订阅表
    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订阅日志表
    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会员订阅记录';
    

    重点代码

    创建订单

    @ApiOperation(value = "创建 pingpong vip 订单")
    @PostMapping("/pingpong/prePay")
    @WriteLog
    public ResultResponse 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();
            }
        }
    }
    
    /**
     *  创建会员订单
     * @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;
    }
    
    /**
     *   预创建订阅订单
     * @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;
    }
    
    /**
     *  检查用户是否重复订阅
     * @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);
        }
    }
    

    接收支付回调用

    /**
     *  交易 通知
     * @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);
        }
    }
    
    // 订阅失败处理
    @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);
    }
    

    计划扣款

    /** job 触发
     *
     *  订阅周期扣款 创建扣款日志 ,发送 扣款任务到 kafka
     * @param date
     */
    @Override
    public void subscriptionDeduction(Date date) {
    
        String batchId = Constant.PingPongPay.generateBatchId(date);
    
        // 获取 到期时间前24小时 订阅状态是订阅中的 订阅
        int page = 0;
        Page renewalOrderPage;
        do {
            PageRequest pageRequest = PageRequest.of(page, 1000, new Sort(Sort.Direction.DESC, "id"));
            renewalOrderPage = renewalOrderRepository.findByTimePage(pingPongProperties.getBeforeExpirationMinutes(), pageRequest);
            page ++;
    
            // 写入续订日志表
            List content = renewalOrderPage.getContent();
    
            if(!CollectionUtils.isEmpty(content)){
                // 过滤状态不是 扣款处理中和扣款重试的订阅
                List 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消费需要扣款的续订任务

    @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();
            }
        }
    }
    
    /**
     *  开始处理计划扣款 后续扣款
     * @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);
    
    }
    
    /**
     *  处理扣款
     * @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监听失败重试

    @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();
            }
        }
    }
    
    /**
     *  扣款失败重试
     * @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);
    }
    

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论