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:
wangjia
2026-07-03 22:28:21 +08:00
parent b3c9d98eae
commit a996593b1c
2 changed files with 61 additions and 2 deletions
+18
View File
@@ -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",
+41
View File
@@ -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), "回调须回 SUCCESSpay 停止重试")
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) {