支付订单之防刷、限量和防重


涉及支付代码的主要三类情况

  • 代码本身涉及有偿使用的三方服务如采用后付款方式的结算,如果出问题没及时发现,下个月结算时会收到一笔数额巨大的账单;
  • 代码涉及虚拟资产的发放,比如积分、优惠券等。比如优惠券可以下单使用,积分可以兑换积分商城的商品。虚拟资产就是具有一定价值的钱,但因不涉及钱和外部资金通道容易产生随意性发放而导致漏洞;
  • 代码涉及真实钱的进出,比如对用户扣款,如果出现非正常的多次重复扣款,小则用户投诉、用户流失,大则被相关管理机构要求停业整改,影响业务。又比如,给用户发放返现的付款功能,如果出现漏洞造成重复付款,涉及B端的可能还好,但涉及C端用户的重复付款可能永远无法追回。

- 拼多多一夜直接被刷大量100元无门槛优惠券的事情就是限量和防刷出了问题。

- 以下是如何在代码层面做好安全兜底的三个例子

开放平台资源的使用需要考虑防刷

背景:

有次账单月结的时候发现,之前每个月是几千元的短信费用,这个月突然变为了几万元。查数据库记录发现,之前是每天发送几千条短信验证码,从某天开始突然变为每天几万条,但注册用户数并没有激增。显然,这是短信接口被刷了。

分析:

短信验证码服务属于开放性服务,由用户侧重复啊,且因为是注册验证码所以不需要登录就可以使用。如果我们的发短信接口像这样没有任何防刷的防护,直接调用三方短信通道,就相当于“裸奔”,很容易被短信轰炸平台利用。

@GetMapping("wrong")
public void wrong() {
    sendSMSCaptcha("13600000000");
}

private void sendSMSCaptcha(String mobile) {
  //调用短信通道
}

解决:

因此对于短信验证码这种开放接口,程序逻辑内需要有防刷逻辑。好的防刷逻辑是,对正常使用的用户毫无影响,只有疑似异常使用的用户才会感受到。对于短信验证码,可以用4种可行方式来防刷。

  1. 只有固定的请求头才能发送验证码
    • 通过请求头中网页或App客户端传给服务端一些额外参数,来判断请求是不是App发起的。
    • 比如,判断是否存在浏览器或手机型号、设备分辨率请求头。
    • 对于那些使用爬虫来抓取短信接口的地址来说,往往只能抓取到URL,而难以分析出请求发送短信还需要额外请求头第一道基本防御。
  2. 只有先到过注册页面才能发送验证码
    • 对于普通用户来说,不管是通过App注册还是H5页面注册,一定是先进入注册页面才能看到发送验证码按钮,再点击发送。
    • 可以在页面或界面打开时请求固定的前置接口,为这个设备开启允许发送验证码的窗口,之后的请求发送验证码才是有效请求。
    • 这种方式可以防御直接绕开固定流程,通过接口直接调用的发送验证码请求,并不会干扰普通用户。
  3. 控制相同手机号的发送次数和发送频次
    • 除非是短信无法收到,否则用户不会请求了验证码后不完成注册流程,再重新求请求;
    • 限制同一手机号每天的最大请求次数。验证码的到达需要时间,太短的发送间隔没有意义,所以还可以控制发送的最短间隔;
    • 比如,可以控制相同手机号一天只能发送10次验证码,最短发送间隔1分钟。
  4. 增加前置图形验证码
    • 短信轰炸平台一般会手机很多免费短信接口,一个接口只会给一个用户发一次短信,所以控制相同手机号发哦是那个次数和间隔的方式不够有效;
    • 考虑对用户体验稍微有影响,但也是最有效的方式做为保底,即将弹出图形验证码作为前置;
    • 除了图形验证码,还可以使用其他更友好的人机验证手段(比如滑动、点击验证码等),
    • 甚至是引入比较新潮的无感知验证码方案(比如,通过判断用户输入手机号的打字节奏,来判断是用户还是机器),来改善用户体验。

总结:

  • 总之,要确保只有正常用户经过正常的流程才能使用开放平台资源并且资源的用了在业务需求合理范围内。
  • 此外,还需要考虑做好短信发送量的实时监控,遇到发送量激增要及时报警。

虚拟资产并不能凭空产生无限使用

背景:

虚拟资产虽然是平台自己生产和控制,但如果生产出来可以立即使用就有立即变现的可能性。比如,因为平台Bug有大量用户领取高额优惠券,并立即下单使用。

分析:

在商家看来,这很可能只是一个用户支付的订单,并不会感知到用户使用平台优惠券的情况;同时,因为平台和商家是事后结算的,所以会马上安排发货.而发货后基本就不可逆转了,一夜之间造成大量资金损失

凭空产生无限的优惠券:

@Slf4j
public class CouponCenter {
    //用于统计发了多少优惠券
    AtomicInteger totalSent = new AtomicInteger(0);
    public void sendCoupon(Coupon coupon) {
        if (coupon != null)
            totalSent.incrementAndGet();
    }

    public int getTotalSentCoupon() {
        return totalSent.get();
    }

    //没有任何限制,来多少请求生成多少优惠券
    public Coupon generateCouponWrong(long userId, BigDecimal amount)              {
        return new Coupon(userId, amount);
    }
}

使用 CouponCenter 的 generateCouponWrong 方法,想发多少优惠券就可以发多少:

@GetMapping("wrong")
public int wrong() {
    CouponCenter couponCenter = new CouponCenter();
    //发送10000个优惠券
    IntStream.rangeClosed(1, 10000).forEach(i -> {
        Coupon coupon = couponCenter.generateCouponWrong(1L, new BigDecimal("100"));
        couponCenter.sendCoupon(coupon);
    });
    return couponCenter.getTotalSentCoupon();
}

更合适的做法,把优惠券看作是一种资源,其生产不是凭空的,而是需要事先申请,理由:

  • 虚拟资产如果是对应到真是金钱上的优惠,那么能发多少取决于运营和财务的核算,应该是有计划,有上限的.有门槛优惠券大量使用至少可以代理大量真实用户,而使用无门槛优惠券下的订单,可能用户一分钱都没有支付;
  • 及时虚拟资产不值钱,大量不合常规的虚拟资产流入时长,也会冲垮虚拟资产的经济体系,造成虚拟货币的极速贬值.有量的控制才有价值.
  • 资产的申请需要利用,甚至需要走流程,这样可以追溯是什么活动需要,谁提出的申请,程序依据申请批次来发放.

解决:

按照以上思路

//优惠券批次
@Data
public class CouponBatch {
    private long id;
    private AtomicInteger totalCount;
    private AtomicInteger remainCount;
    private BigDecimal amount;//固定张数的优惠券
    private String reason;//申请原因
}

在业务需要发放优惠券的时候,先申请批次,然后再通过批次发放优惠券:

@GetMapping("right")
public int right() {
    CouponCenter couponCenter = new CouponCenter();
    //申请批次    
    CouponBatch couponBatch = couponCenter.generateCouponBatch();
    IntStream.rangeClosed(1, 10000).forEach(i -> {
        Coupon coupon = couponCenter.generateCouponRight(1L, couponBatch);
        //发放优惠券
        couponCenter.sendCoupon(coupon);
    });
    return couponCenter.getTotalSentCoupon();
}

generateCouponBatch 方法申请批次时,设定了这个批次包含 100 张优惠券。在通过 generateCouponRight 方法发放优惠券时,每发一次都会从批次中扣除一张优惠券,发完了就没有了:

public Coupon generateCouponRight(long userId, CouponBatch couponBatch) {
    if (couponBatch.getRemainCount().decrementAndGet() >= 0) {
        return new Coupon(userId, couponBatch.getAmount());
    } else {
        log.info("优惠券批次 {} 剩余优惠券不足", couponBatch.getId());
        return null;
    }
}

//生产中,这里根据CouponBatch在数据库中插入一定量的Coupon记录,每一个优惠券都有唯一的ID,可跟踪,可注销
public CouponBatch generateCouponBatch() {
    CouponBatch couponBatch = new CouponBatch();
    couponBatch.setAmount(new BigDecimal("100"));
    couponBatch.setId(1L);
    couponBatch.setTotalCount(new AtomicInteger(100));
    couponBatch.setRemainCount(couponBatch.getTotalCount());
    couponBatch.setReason("XXX活动");
    return couponBatch;
}

支付一定和订单挂钩并且实现幂等

分析:

  • 第一,任何资金操作都需要在平台侧生产业务属性的订单,可以是优惠券发放订单,也可以是借款订单,一定是先有订单再去支付;
    • 同时订单的产生需要业务属性,比如,返现发放订单必须关联到原先的商品订单产生;
    • 比如,借款订单必须关联到同一个接口合同产生
  • 第二,一定要做好防重,也就是冥等处理,并且冥等处理必须是全链路的.
    • 全链路指,从前到后都需要相同的业务订单号来贯穿,实现最终的支付防重.
//错误:每次使用UUID作为订单号
@GetMapping("wrong")
public void wrong(@RequestParam("orderId") String orderId) {
    PayChannel.pay(UUID.randomUUID().toString(), "123", new BigDecimal("100"));
}

//正确:使用相同的业务订单号
@GetMapping("right")
public void right(@RequestParam("orderId") String orderId) {
    PayChannel.pay(orderId, "123", new BigDecimal("100"));
}
//三方支付通道
public class PayChannel {
    public static void pay(String orderId, String account, BigDecimal amount) {
        ...
    }
}

解决:

  • 对于支付操作,一定是调用三方支付公司的接口或银行也可进行处理.这些接口都会有商户订单号的概念,
    • 对于相同的商户订单号,无法进行重复的资金处理,所以三方公司的接口可以实现唯一订单号的冥等处理.
  • 业务系统实现资金操作容易犯的错是,没有自始至终使用一个订单号作为商户订单号,透传给三方支付接口.
    • 大的互联网公司会把支付独立一个部门.并且会针对支付做聚合操作,内部维护一个支付订单号,使用支付订单号和三方支付交互
    • 最终虽然商品订单一个,但支付订单多个,相同的商品订单因为产生多个支付订单导致多次支付
  • 如果出现重复扣款,可以给用户进行退款操作,但给用户付款的操作一旦出现重复付款,就很难把钱追回来了,所以更要小心
  • 这就是全链路的意义,从一开始就需要先有业务订单产生,然后使用相同的业务订单号一直贯穿到最后的资金通道,才能真正避免重复资金操作.

 如何及时发现系统正在被攻击或利用

  • 防重防刷都是事前手段,如何及时发现我们的系统正在被攻击或利用?
  • 监控-关键点在于报警阈值怎么设置
    • 可以对比昨天同时,上周同时的量,发现差异达到一定百分比报警,而且报警需要有升级机制;
    • 此外,有时候大盘很大的话,活动给整个大盘带来的变化不明显,如果进行整体监控可能处理问题也无法及时发现,因此可以考虑对于活动做独立的监控报警.

定期对账问题

  • 任何第三方资源的使用一般都会定期对账,如果在对账中发现我们系统记录调用量低于对方系统记录的调用量,什么问题如何解决?
  • 如,在事务内调用外部接口,调用超时后本地事务回滚本地就没有留下数据,更合适的做法:
    • 请求发出之前先记录请求数据提交事务,记录状态为未知
    • 发布调用外部接口的请求,如果可以拿到明确的结果,则更新数据库中记录的状态为成功或失败.如果出现超时或未知异常,不能假设第三方接口调用失败,需要通过查询接口查询明确的结果.
    • 写一个定时任务补偿数据库中所有未知状态的记录,从第三方接口同步结果
  • 对账时一定要对两边,不管哪方数据缺失都可能是因为程序逻辑有bug,需要重视.
  • 任何涉及第三方系统的交互,都建议在数据库中保持明细的请求/响应报文,方便出问题的时候定位bug根因

 原文链接:https://time.geekbang.org/column/article/237060

相关