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) {