涉及支付代碼的主要三類情況
- 代碼本身涉及有償使用的三方服務如采用后付款方式的結算,如果出問題沒及時發現,下個月結算時會收到一筆數額巨大的賬單;
- 代碼涉及虛擬資產的發放,比如積分、優惠券等。比如優惠券可以下單使用,積分可以兌換積分商城的商品。虛擬資產就是具有一定價值的錢,但因不涉及錢和外部資金通道容易產生隨意性發放而導致漏洞;
- 代碼涉及真實錢的進出,比如對用戶扣款,如果出現非正常的多次重復扣款,小則用戶投訴、用戶流失,大則被相關管理機構要求停業整改,影響業務。又比如,給用戶發放返現的付款功能,如果出現漏洞造成重復付款,涉及B端的可能還好,但涉及C端用戶的重復付款可能永遠無法追回。
- 拼多多一夜直接被刷大量100元無門檻優惠券的事情就是限量和防刷出了問題。
- 以下是如何在代碼層面做好安全兜底的三個例子
開放平台資源的使用需要考慮防刷
背景:
有次賬單月結的時候發現,之前每個月是幾千元的短信費用,這個月突然變為了幾萬元。查數據庫記錄發現,之前是每天發送幾千條短信驗證碼,從某天開始突然變為每天幾萬條,但注冊用戶數並沒有激增。顯然,這是短信接口被刷了。
分析:
短信驗證碼服務屬於開放性服務,由用戶側重復啊,且因為是注冊驗證碼所以不需要登錄就可以使用。如果我們的發短信接口像這樣沒有任何防刷的防護,直接調用三方短信通道,就相當於“裸奔”,很容易被短信轟炸平台利用。
@GetMapping("wrong") public void wrong() { sendSMSCaptcha("13600000000"); } private void sendSMSCaptcha(String mobile) { //調用短信通道 }
解決:
因此對於短信驗證碼這種開放接口,程序邏輯內需要有防刷邏輯。好的防刷邏輯是,對正常使用的用戶毫無影響,只有疑似異常使用的用戶才會感受到。對於短信驗證碼,可以用4種可行方式來防刷。
- 只有固定的請求頭才能發送驗證碼
- 通過請求頭中網頁或App客戶端傳給服務端一些額外參數,來判斷請求是不是App發起的。
- 比如,判斷是否存在瀏覽器或手機型號、設備分辨率請求頭。
- 對於那些使用爬蟲來抓取短信接口的地址來說,往往只能抓取到URL,而難以分析出請求發送短信還需要額外請求頭第一道基本防御。
- 只有先到過注冊頁面才能發送驗證碼
- 對於普通用戶來說,不管是通過App注冊還是H5頁面注冊,一定是先進入注冊頁面才能看到發送驗證碼按鈕,再點擊發送。
- 可以在頁面或界面打開時請求固定的前置接口,為這個設備開啟允許發送驗證碼的窗口,之后的請求發送驗證碼才是有效請求。
- 這種方式可以防御直接繞開固定流程,通過接口直接調用的發送驗證碼請求,並不會干擾普通用戶。
- 控制相同手機號的發送次數和發送頻次
- 除非是短信無法收到,否則用戶不會請求了驗證碼后不完成注冊流程,再重新求請求;
- 限制同一手機號每天的最大請求次數。驗證碼的到達需要時間,太短的發送間隔沒有意義,所以還可以控制發送的最短間隔;
- 比如,可以控制相同手機號一天只能發送10次驗證碼,最短發送間隔1分鍾。
- 增加前置圖形驗證碼
- 短信轟炸平台一般會手機很多免費短信接口,一個接口只會給一個用戶發一次短信,所以控制相同手機號發哦是那個次數和間隔的方式不夠有效;
- 考慮對用戶體驗稍微有影響,但也是最有效的方式做為保底,即將彈出圖形驗證碼作為前置;
- 除了圖形驗證碼,還可以使用其他更友好的人機驗證手段(比如滑動、點擊驗證碼等),
- 甚至是引入比較新潮的無感知驗證碼方案(比如,通過判斷用戶輸入手機號的打字節奏,來判斷是用戶還是機器),來改善用戶體驗。
總結:
- 總之,要確保只有正常用戶經過正常的流程才能使用開放平台資源並且資源的用了在業務需求合理范圍內。
- 此外,還需要考慮做好短信發送量的實時監控,遇到發送量激增要及時報警。
虛擬資產並不能憑空產生無限使用
背景:
虛擬資產雖然是平台自己生產和控制,但如果生產出來可以立即使用就有立即變現的可能性。比如,因為平台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