fix(backend): 特惠限次 webhook 兜底——第二笔已支付特惠单只标 paid 不叠加时长
按 pay-contract 看板要求:续期入账前事务内再查本店是否已有另一笔 paid 的 promo_first_month(防绕过前端/并发双买);命中则跳过 entitle、记 ALERT 告警, 回调仍回 SUCCESS(钱已收,退款人工处理)。补对应测试(双买不叠加/正常套餐不受影响)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -279,9 +279,27 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
|
||||
if !ok {
|
||||
return ErrUnknownPlan
|
||||
}
|
||||
// 特惠限次 webhook 兜底(防绕过前端/并发双买):入账前事务内再查一次,
|
||||
// 本店已有另一笔已支付特惠单 → 本单只标 paid 不叠加时长,记告警(契约 INTEGRATION-BOARD 要求)
|
||||
entitleOK := true
|
||||
if p.ProductBizCode == PromoBizCode {
|
||||
var dup int64
|
||||
if err := tx.Model(&model.LicensePurchase{}).
|
||||
Where("shop_id = ? AND product_biz_code = ? AND status = ? AND id <> ?",
|
||||
p.ShopID, PromoBizCode, "paid", p.ID).
|
||||
Count(&dup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if dup > 0 {
|
||||
entitleOK = false
|
||||
log.Printf("[pay] ALERT promo double-claim out_trade_no=%s shop=%d:本店已享受过首月特惠,本单不叠加时长", outTradeNo, p.ShopID)
|
||||
}
|
||||
}
|
||||
if entitleOK {
|
||||
if err := entitle(tx, p.ShopID, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
shopID = p.ShopID
|
||||
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
|
||||
"status": "paid",
|
||||
|
||||
@@ -311,6 +311,47 @@ func TestCreatePurchase_PromoOncePerShop(t *testing.T) {
|
||||
assert.False(t, used)
|
||||
}
|
||||
|
||||
// webhook 兜底(契约 INTEGRATION-BOARD):并发/绕过前端产生第二笔特惠单时,
|
||||
// 回调仍回 SUCCESS 并标 paid(钱已收),但不叠加时长,防止绕过购买接口的限购。
|
||||
func TestHandleCallback_PromoDoubleClaimNoEntitle(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PAY011")
|
||||
svc := newTestPaySvc(db, "http://pay.invalid")
|
||||
|
||||
// 并发双买:两笔 pending 特惠单同时存在,先后收到回调
|
||||
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-a")
|
||||
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-b")
|
||||
|
||||
body := callbackBody("yanmei-promo-a", PromoBizCode, "1.00")
|
||||
ts, nonce, sign := signedCallbackArgs(body)
|
||||
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
|
||||
|
||||
var lic model.License
|
||||
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
|
||||
expires1 := *lic.ExpiresAt
|
||||
assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1, "第一笔正常续期 30 天")
|
||||
|
||||
// 第二笔回调:标 paid 但不再叠加
|
||||
body2 := callbackBody("yanmei-promo-b", PromoBizCode, "1.00")
|
||||
ts2, nonce2, sign2 := signedCallbackArgs(body2)
|
||||
require.NoError(t, svc.HandleCallback(body2, ts2, nonce2, sign2), "回调须回 SUCCESS,pay 停止重试")
|
||||
|
||||
var p2 model.LicensePurchase
|
||||
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-promo-b").First(&p2).Error)
|
||||
assert.Equal(t, "paid", p2.Status, "第二笔仍标 paid(钱已收,退款人工处理)")
|
||||
|
||||
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
|
||||
assert.True(t, lic.ExpiresAt.Equal(expires1), "第二笔特惠单不得叠加时长")
|
||||
|
||||
// 兜底只限特惠:同店正常套餐单不受影响
|
||||
createPendingPurchase(t, db, shop.ID, "monthly_standard", "299.00", "yanmei-normal-c")
|
||||
body3 := callbackBody("yanmei-normal-c", "monthly_standard", "299.00")
|
||||
ts3, nonce3, sign3 := signedCallbackArgs(body3)
|
||||
require.NoError(t, svc.HandleCallback(body3, ts3, nonce3, sign3))
|
||||
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
|
||||
assert.InDelta(t, 60, daysFromNow(lic.ExpiresAt), 1, "正常套餐照常叠加")
|
||||
}
|
||||
|
||||
// ---------- Status ----------
|
||||
|
||||
func TestStatus_ScopedToShop(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user