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

Posted

技术标签:

【中文标题】处理 Stripes payment_intent.succeeded Webhook,如果它与来自客户端的回发竞争以在数据库中创建实体【英文标题】:Handling Stripes payment_intent.succeeded Webhook if it competes with a post back from the client to create an entity in the DB 【发布时间】:2021-09-27 08:30:29 【问题描述】:

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

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

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

问题 -

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

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

但是假设两个入口点都查询并返回 null,所以现在两个入口点继续创建一个新的“支付”实体并尝试将其保存在数据库中。一个会成功,一个会失败,经常会创建 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; 

这是来自客户端的一些代码,用于调用条带,然后发布到我的服务器

// 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

    
    

【问题讨论】:

我又来了!正如我在另一个线程上回答的那样,您应该考虑构建一个排队系统,以串行处理对数据库的任何读/写,包括传入的 webhook 事件以及对服务器的客户端请求。 我不认为创建队列是一个好的解决方案。 为什么不在 webhook 监听器中处理异常 - 如果你收到错误,并且记录现在存在于数据库中,继续正常吗?对于暂时性错误,stripes webhook 内置了一些重试逻辑,因此无论如何应该相当健壮 - stripe.com/docs/webhooks/best-practices#retry-logic 嗨,山姆。我认为这可能是我必须做的,但我必须处理控制器端和 webhook 端的异常,因为两者都将参与竞争数据库行条目的创建。为这种类型的每个行条目捕获异常似乎并不合理?当每个入口点创建尝试创建数据库行时会发生什么。 【参考方案1】:

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

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

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

【讨论】:

嗨乔恩。我刚刚阅读了您提供的链接,但我有点困惑并且有一个或 2 个问题。我不需要更新此行,只需在购买时添加一次。在我的场景中,UserA(客户端发布到控制器)和 UserB(webhook)将争夺创建并将其添加到表中,那么这个 RowVersion 实现是否适用于我的场景?好像不会!还是 RowVersion 通常适用于用不同的值更新同一行? 抱歉耽搁了。据我了解,rowversion 旨在防止使用不同的值更新同一行,因此它应该防止来自 2 个不同来源的争用,因为它是在数据库中维护的。 嗨乔恩。也许我误解了你,但你说“我的理解是 rowversion 是为了防止用不同的值更新同一行”,但我永远不会在创建该行后更新它。它被创建一次,就是这样。争用来自行的创建,而不是一些更新。所以,除非我错误地阅读了您留下链接的文档,否则我认为我不能使用它??

以上是关于处理 Stripes payment_intent.succeeded Webhook,如果它与来自客户端的回发竞争以在数据库中创建实体的主要内容,如果未能解决你的问题,请参考以下文章

将 Stripe 平台客户克隆到关联账户客户时出现问题 - 没有这样的 payment_intent: 'pi_abc...zyx'

第一次使用 Tomcat 和 Stripes 框架

Code=50 “No such payment_intent” 当确认条带的支付意图时

阿迪达斯neo 3stripes是啥意思

我可以使用 Stripes PaymentIntent 更改订单金额吗?

R语言使用ggplot2可视化:使用ggpattern包在分组条形图中添加自定义条纹图案添加阴影条纹或其他图案或纹理(add hatches, stripes or another pattern