乐趣区

支付系统设计实现3扫码支付

扫码支付场景

用户在食堂,超市进行限订金额的交易时,可以通过出示支付二维码,商家使用扫码器进行扫码,所有收款操作由商家端完成,进行免密码的支付。其中用户的手机可以是离线的,但是扫码器必须是联网的。

业务分析

根据上述支付宝给出的流程图,我们可以将步骤梳理如下:

  1. 用户打开 APP, 展示二维码。此时不论手机是否联网,APP 都能生成二维码,说明二维码是在 APP 生成的
  2. 收银员生成订单,表明订单的信息,金额是在收银端完成的,也就是说此时的订单只是产生了,但是并没有和具体的 APP 账户相关联。
  3. 收银员通过扫码器扫描用户展示的二维码,进行交易。这一步商户后台会进行订单的支付操作。也就是说扫码器获得的 二维码是包含了用户信息并且可以解析获得的
  4. 收银端实时返回交易结果。
  5. APP 异步收到交易结果通知。

根据以上步骤,我们绘制如下时序图:

接口整理

    /**
     * 获取被动扫码支付 token
     * @param accountId 账户 id
     * @return 仅以一次有效的 token
     */
    String getPayScanPassivityToken(String accountId);
    
    /**
     * 被动扫码消费
     * @param payCode 支付码
     * @param consumeAmount 支付金额
     * @param businessId 业务 id(食堂订单 id,洗衣订单 id)* @param businessIdType 业务类型
     * @param tradeRemark 交易备注
     * @return 支付账户 id
     */
    String consumeByScanPassivity(String payCode, int consumeAmount, String businessId, WalletConsumeType businessIdType,String tradeRemark) throws InvalidOperationException;
    
     /**
     * 根据支付 payCode 查询支付状态
     * @param payCode 支付码
     * @return 支付状态
     */
    WalletOrderStatus getWalletOrderStatusByToken(String payCode) throws InvalidOperationException;

这里需要注意的是,APP 每次获得的支付令牌 token,二维码是在 token 的基础上进行加密绘制的,二维码本质上是一个支付码 payCode, 扫码器每次获得是 payCode, 用于交易的是 payCode, 需要插叙支付状态的也是 payCode。如果用户一开始就没有 token,那么 APP 是无法进行二维码绘制的。我们使用 payCode 而不是 token 进行支付的目的就是离线可以多次支付

payCode 的校验

APP 生成 payCode 的时间与服务器校验 payCode 的时间是有误差的,我们限定的是前后 15 分钟有效。如果 payCode 在 60 * 15 个备选数据中有一个符合,我们都认为校验成功。下面是关键代码:

/**
     * 检验 payCode
     * @param payCode 支付码
     * @return 返回账户 id
     */
    private String checkPayCode(String payCode) throws InvalidOperationException {log.info("===" + payCode);
        final int payCodeLessLength = 24;
        // 允许 15 分钟有效
        final long payCodeTimeStep = 60 * 15;
        final String payCodePrivateKey = "5d4*********************************";

        if(StringUtils.isEmpty(payCode) || payCode.length() <= payCodeLessLength){throw new InvalidOperationException("支付码格式异常, 请重新扫码");
        }
        String payTokenStr = payCode.substring(payCode.length() - payCodeLessLength);
        Optional<WalletPayTokenEntity> payTokenOptional = walletPayTokenJpaRepository.findByToken(payTokenStr);
        if(!payTokenOptional.isPresent()){log.error("支付码不存在:" + payCode);
            throw new InvalidOperationException("支付码不存在, 请刷新二维码");
        }
        WalletPayTokenEntity payToken = payTokenOptional.get();
        if(!EntityStatusEnum.VALID.getValue().equals(payToken.getEntityStatus())){throw new InvalidOperationException("支付码已经消费, 请刷新二维码");
        }
        long timestamp = LocalDateTimeUtils.getSecondsByTime(LocalDateTime.now());
        String accountId = payToken.getAccountId();
        for (long i = timestamp - payCodeTimeStep; i <= timestamp + payCodeTimeStep; i++) {
            String raw = accountId + i + payCodePrivateKey;
            String signCode = DigestUtils.sha1Hex(raw.getBytes()) + payTokenStr;
            if(payCode.equals(signCode)){payToken.consume(payCode);
                walletPayTokenJpaRepository.save(payToken);
                return accountId;
            }
        }
        log.error("支付码非法:" + payCode);
        throw new InvalidOperationException("支付码非法, 请刷新二维码");
    }
退出移动版