From a996593b1cb1af2123e4706e537e4a56ce642f4d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 3 Jul 2026 22:28:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E7=89=B9=E6=83=A0=E9=99=90?= =?UTF-8?q?=E6=AC=A1=20webhook=20=E5=85=9C=E5=BA=95=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E7=AC=94=E5=B7=B2=E6=94=AF=E4=BB=98=E7=89=B9?= =?UTF-8?q?=E6=83=A0=E5=8D=95=E5=8F=AA=E6=A0=87=20paid=20=E4=B8=8D?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 pay-contract 看板要求:续期入账前事务内再查本店是否已有另一笔 paid 的 promo_first_month(防绕过前端/并发双买);命中则跳过 entitle、记 ALERT 告警, 回调仍回 SUCCESS(钱已收,退款人工处理)。补对应测试(双买不叠加/正常套餐不受影响)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o --- backend/internal/service/pay.go | 22 +++++++++++++-- backend/internal/service/pay_test.go | 41 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 7a7aa7b..078be9d 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -279,8 +279,26 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string if !ok { return ErrUnknownPlan } - if err := entitle(tx, p.ShopID, plan); err != nil { - return err + // 特惠限次 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{ diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index c432452..53ac1ee 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -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) {