扫码支付场景
用户在食堂,超市进行限订金额的交易时,可以通过出示支付二维码,商家使用扫码器进行扫码,所有收款操作由商家端完成,进行免密码的支付。其中用户的手机可以是离线的,但是扫码器必须是联网的。
业务分析
根据上述支付宝给出的流程图,我们可以将步骤梳理如下:
- 用户打开 APP, 展示二维码。此时不论手机是否联网,APP 都能生成二维码,说明二维码是在 APP 生成的。
- 收银员生成订单,表明订单的信息,金额是在收银端完成的,也就是说此时的订单只是产生了,但是并没有和具体的 APP 账户相关联。
- 收银员通过扫码器扫描用户展示的二维码,进行交易。这一步商户后台会进行订单的支付操作。也就是说扫码器获得的 二维码是包含了用户信息并且可以解析获得的。
- 收银端实时返回交易结果。
- 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("支付码非法, 请刷新二维码");
}