处理 Stripes payment_intent.succeeded Webhook 如果它与来自客户端的回发竞争以在数据库中创建实体

问题描述

在使用 Stripe 向信用卡收费时,我需要一些有关我的应用程序工作流程的建议。

场景 1 - 我没有为 payment_intent.succeeded 使用任何网络钩子,所以当我在客户端用 Javascript 调用 stripe.confirmCardPayment 时 并收到paymentIntent,然后将其发布到我的服务器并使用称为“SavePayment()”的某种方法在“付款”表中创建一个条目,其中将存储所有详细信息(卡ID、exp 月份、金额等)。保存到数据库后,我可以将详细信息返回给客户(赚取的积分、付款成功消息等)。然后我们就完成了!

场景 2 客户端(用户)在调用 Stripe 对卡收费后关闭浏览器,但在它可以回发到我的服务器以添加“支付”实体之前。所以现在我对 payment_intent.succeeded 使用网络钩子,因为其他人建议这样做以实现冗余。

问题 -

因为 webhook 是立即触发的,在 Stripe 向卡收费后,我的服务器可能会收到两个不同的入口点(客户端回发到服务器以保存付款和 Stripes webhook 触发事件),以创建“付款”我数据库中的实体。

在这不是什么大问题,因为两个入口点都可以根据“Payment”实体的唯一标识符 (PaymentIntentId) 查询它是否存在于数据库中。

但是假设两个入口点都查询并返回空值,所以现在两个入口点继续创建一个新的“支付”实体并尝试将其保存在数据库中。一个会成功,一个现在会失败,经常创建 sql Server 抛出的唯一标识符约束异常。

解决方案? - 在我的数据库中创建实体时,这似乎不是理想的工作流程/场景,其中可能会频繁抛出多个异常。是否有更好的工作流程,还是我坚持以这种方式实施?

这是我要查看的一些代码/suedo 代码

public class Payment : BaseEntity
{
    public string PaymentIntentId { get; set; }
    public int Amount { get; set; }
    public string Currency { get; set; }
    public string CardBrand { get; set; }
    public string CardExpMonth { get; set; }
    public string CardExpYear { get; set; }
    public int CardFingerPrint { get; set; }
    public string CardLastFour { get; set; }
    public PaymentStatus Status { get; set; }
    public int StripeFee { get; set; }
    public int PointsAwarded { get; set; }
    public int PointsBefore { get; set; }
    public int PointsAfter { get; set; }
    public string StripeCustomer { get; set; }
    public int UserId { get; set; }
    public User User { get; set; }
}

这是客户端调用stripe然后发布到我的服务器的一些代码

// submit button is pressed 
// do some work here then call Stripe

from(this.stripe.confirmCardPayment(this.paymentIntent.clientSecret,data)).subscribe((result: any) => {

  if (result.paymentIntent) {

    let payment = {
      paymentIntentId: result.paymentIntent.id,amount: result.paymentIntent.amount,currency: result.paymentIntent.currency,// fill in other fields
    };

    this.accountService.savePayment(payment).subscribe(response => {

      if (response.status === 'Success') {
        // do some stuff here
        this.alertService.success("You're purchase was successful");
        this.router.navigateByUrl('/somepage');
      }

      if (response.status === 'Failed') {
        this.alertService.danger("Failed to process card");
      }

    },error => {
      console.log(error);
      this.alertService.danger("Oh no! Something happened,please contact the help desk.");
    }).add(() => {
      this.loadingPayment = false;
    });

  } else {
    this.loadingPayment = false;
    this.alertService.danger(result.error.message);
  }

});

这里是保存“支付”实体的服务器控制器

        [HttpPost("savepayment")]
    public async Task<ActionResult> SavePayment(StripePaymentDto paymentDto)
    {
        var userFromrepo = await _userManager.FindByEmailFromClaimsPrinciple(HttpContext.User);
        
        if (userFromrepo == null) 
            return Unauthorized(new ApiResponse(401));
        // this calls the Stripe API to get the PaymentIntent (just incase the client changed it)
        var paymentIntent = await _paymentService.RetrievePaymentIntent(paymentDto.PaymentIntentId);
        if (paymentIntent == null) return BadRequest(new ApiResponse(400,"Problem Retrieving Payment Intent"));

        var payment = _mapper.Map<StripePaymentDto,StripePayment>(paymentDto);
        payment.UserId = userFromrepo.Id;

        if (paymentIntent.Status == "succeeded") {
           
            // fill in all the necessary fields
            // left out for brevity

        } else if (paymentIntent.Status == "requires_payment_method") {
            payment.Status = PaymentStatus.Failed;
            _logger.Loginformation("Payment Intent is not successful. Status: " + paymentIntent.Status + " PaymentIntentId: " + paymentIntent.PaymentIntentId);
            // send payment failure email
        } else {
            // don't kNow if this will be needed
            payment.Status = PaymentStatus.Pending;
        }

        _unitOfWork.Repository<StripePayment>().Add(payment);

        var success = await _unitOfWork.Complete();
        if (success > 0) {
            if (payment.Status == PaymentStatus.Success) {
                // send email
            }
            return Ok(_mapper.Map<StripePayment,StripePaymentDto>(payment));
        }
        
        return BadRequest(new ApiResponse(400,"Failed to save payment"));

    }
    

这是 Stripe 网络钩子

    [HttpPost("webhook")]
    public async Task<ActionResult> StripeWebhook()
    {
        var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();

        // if this doesn't match we get an exception (sig with whSec) 
        var stripeEvent = EventUtility.ConstructEvent(json,Request.Headers["Stripe-Signature"],_whSecret);

        PaymentIntent intent; 

        switch (stripeEvent.Type)
        {
            case "payment_intent.succeeded":
                intent = (PaymentIntent)stripeEvent.Data.Object;
                _logger.Loginformation("Payment Succeeded: ",intent.Id);
                this.ProcessSuccess(intent);
                // order  = await _paymentService.UpdateOrderPaymentSucceeded(intent.Id);
                // _logger.Loginformation("Order updated to payment received: ",order.Id);
                break;
            case "payment_intent.payment_Failed":
                intent = (PaymentIntent)stripeEvent.Data.Object;
                _logger.Loginformation("Payment Failed: ",intent.Id);
                // _logger.Loginformation("Payment Failed: ",order.Id);
                break;
        }

        return new EmptyResult();
    }

    private async void ProcessSuccess(PaymentIntent paymentIntent) {
        
        var spec = new PaymentsWithtypespecification(paymentIntent.Id);
        var paymentFromrepo = await _unitOfWork.Repository<StripePayment>().GetEntityWithSpec(spec);

        if (paymentFromrepo == null) {
            // create one and add it
            var payment = _mapper.Map<PaymentIntent,StripePayment>(paymentIntent);
            payment.UserId = Convert.ToInt32(paymentIntent.Metadata["userid"]);
        }

        // finish work here and then save to DB

    }
    

解决方法

下面的要点。我很欣赏你的目标。经过一番思考,我的最终分析是:为了防止数据库中多个来源的重复记录,应该使用唯一索引。 (您正在使用)

现在通过使用唯一索引,数据库将抛出异常,代码必须妥善处理。因此,答案是您正在按照我和其他人多年来的方式进行操作。不幸的是,一旦您进入数据库层,我不知道有任何其他方法可以避免异常。

很好的问题,即使答案不是您希望的。