feat: 添加支付回调链路(支付成功 → 订单状态流转 → SignalR 推送)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 12s

- 新增 ProcessPaymentCallbackCommand + Handler(幂等处理)
- 丰富 PaymentSucceededEvent(StoreId、OrderNo、Channel 等字段)
- 新增 PaymentSucceededConsumer(支付成功 → NewOrder 推送)
- 新增 PaymentCallbackController(微信/支付宝预留 + 内部测试端点)
- Program.cs 注册 PaymentSucceededConsumer
This commit is contained in:
2026-02-27 13:09:25 +08:00
parent bb2ff6167e
commit 1e5f0b2f93
5 changed files with 340 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using TakeoutSaaS.Application.Messaging.Events;
using TakeoutSaaS.TenantApi.Hubs;
namespace TakeoutSaaS.TenantApi.Consumers;
/// <summary>
/// 支付成功事件消费者 — 推送新订单到看板。
/// </summary>
public sealed class PaymentSucceededConsumer(IHubContext<OrderBoardHub> hubContext)
: IConsumer<PaymentSucceededEvent>
{
/// <inheritdoc />
public async Task Consume(ConsumeContext<PaymentSucceededEvent> context)
{
var e = context.Message;
var group = $"store:{e.TenantId}:{e.StoreId}";
// 1. 支付成功 = 新订单出现在待接单列,推送 NewOrder
await hubContext.Clients.Group(group).SendAsync("NewOrder", new
{
e.OrderId,
e.OrderNo,
e.Amount,
e.StoreId,
e.Channel,
e.DeliveryType,
e.CustomerName,
e.ItemsSummary,
e.PaidAt
}, context.CancellationToken);
}
}

View File

@@ -0,0 +1,99 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Payments.Commands;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 支付回调接口。
/// </summary>
[ApiVersion("1.0")]
[Route("api/tenant/v{version:apiVersion}/payment/callback")]
public sealed class PaymentCallbackController(
IMediator mediator,
StoreContextService storeContextService) : BaseApiController
{
/// <summary>
/// 微信支付回调(预留,签名验证后续接入 SDK
/// </summary>
[HttpPost("wechat")]
[AllowAnonymous]
public async Task<IActionResult> WeChatCallback(CancellationToken cancellationToken)
{
// TODO: 接入微信支付 V3 SDK 后实现签名验证与报文解析
return Ok(new { code = "SUCCESS", message = "成功" });
}
/// <summary>
/// 支付宝回调(预留,签名验证后续接入 SDK
/// </summary>
[HttpPost("alipay")]
[AllowAnonymous]
public async Task<IActionResult> AlipayCallback(CancellationToken cancellationToken)
{
// TODO: 接入支付宝 RSA2 SDK 后实现签名验证与报文解析
return Ok("success");
}
/// <summary>
/// 内部模拟支付回调(仅开发环境)。
/// </summary>
[HttpPost("internal")]
[Authorize]
public async Task<ApiResponse<bool>> InternalCallback(
[FromBody] InternalPaymentCallbackRequest request,
CancellationToken cancellationToken)
{
var (_, tenantId, _) = storeContextService.GetRequiredContext();
var result = await mediator.Send(new ProcessPaymentCallbackCommand
{
OrderNo = request.OrderNo,
ChannelTransactionId = request.TransactionId ?? $"internal-{Guid.NewGuid():N}",
Method = request.Method ?? PaymentMethod.WeChatPay,
Amount = request.Amount,
PaidAt = request.PaidAt ?? DateTime.UtcNow,
TenantId = tenantId,
RawPayload = "internal-test"
}, cancellationToken);
return ApiResponse<bool>.Ok(result);
}
}
/// <summary>
/// 内部模拟支付回调请求体。
/// </summary>
public sealed record InternalPaymentCallbackRequest
{
/// <summary>
/// 订单号。
/// </summary>
public required string OrderNo { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public required decimal Amount { get; init; }
/// <summary>
/// 支付流水号(可选)。
/// </summary>
public string? TransactionId { get; init; }
/// <summary>
/// 支付方式(可选,默认微信)。
/// </summary>
public PaymentMethod? Method { get; init; }
/// <summary>
/// 支付时间(可选,默认当前时间)。
/// </summary>
public DateTime? PaidAt { get; init; }
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Payments.Commands;
/// <summary>
/// 支付回调处理命令。
/// </summary>
public sealed record ProcessPaymentCallbackCommand : IRequest<bool>
{
/// <summary>
/// 商户订单号。
/// </summary>
public required string OrderNo { get; init; }
/// <summary>
/// 第三方支付流水号。
/// </summary>
public required string ChannelTransactionId { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public required PaymentMethod Method { get; init; }
/// <summary>
/// 实付金额。
/// </summary>
public required decimal Amount { get; init; }
/// <summary>
/// 支付时间。
/// </summary>
public required DateTime PaidAt { get; init; }
/// <summary>
/// 租户标识。
/// </summary>
public required long TenantId { get; init; }
/// <summary>
/// 原始回调报文(留存)。
/// </summary>
public string? RawPayload { get; init; }
}

View File

@@ -0,0 +1,132 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Payments.Commands;
using TakeoutSaaS.Application.Messaging;
using TakeoutSaaS.Application.Messaging.Abstractions;
using TakeoutSaaS.Application.Messaging.Events;
using TakeoutSaaS.Domain.Orders.Entities;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Entities;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Payments.Handlers;
/// <summary>
/// 支付回调处理器。
/// </summary>
public sealed class ProcessPaymentCallbackCommandHandler(
IOrderRepository orderRepository,
IPaymentRepository paymentRepository,
IEventPublisher eventPublisher,
IIdGenerator idGenerator,
ILogger<ProcessPaymentCallbackCommandHandler> logger)
: IRequestHandler<ProcessPaymentCallbackCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(ProcessPaymentCallbackCommand request, CancellationToken cancellationToken)
{
// 1. 通过 OrderNo 查找订单
var order = await orderRepository.FindByOrderNoAsync(request.OrderNo, request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "订单不存在");
// 2. 幂等检查:已支付则直接返回
if (order.PaymentStatus == PaymentStatus.Paid)
{
logger.LogWarning("订单 {OrderNo} 重复支付回调,已忽略", request.OrderNo);
return true;
}
// 3. 校验订单状态
if (order.Status != OrderStatus.PendingPayment)
{
throw new BusinessException(ErrorCodes.BadRequest, $"订单状态 {order.Status} 不允许支付");
}
// 4. 创建支付记录
var paymentRecord = new PaymentRecord
{
Id = idGenerator.NextId(),
TenantId = order.TenantId,
OrderId = order.Id,
Method = request.Method,
Status = PaymentStatus.Paid,
Amount = request.Amount,
ChannelTransactionId = request.ChannelTransactionId,
PaidAt = request.PaidAt,
Payload = request.RawPayload
};
await paymentRepository.AddPaymentAsync(paymentRecord, cancellationToken);
// 5. 更新订单支付状态与订单状态
order.PaymentStatus = PaymentStatus.Paid;
order.PaidAmount = request.Amount;
order.PaidAt = request.PaidAt;
order.Status = OrderStatus.AwaitingPreparation;
await orderRepository.UpdateOrderAsync(order, cancellationToken);
// 6. 写入状态流转记录
var history = new OrderStatusHistory
{
Id = idGenerator.NextId(),
OrderId = order.Id,
TenantId = order.TenantId,
Status = OrderStatus.AwaitingPreparation,
Notes = "支付成功,进入待接单",
OccurredAt = request.PaidAt
};
await orderRepository.AddStatusHistoryAsync(history, cancellationToken);
// 7. 持久化
await orderRepository.SaveChangesAsync(cancellationToken);
// 8. 构建商品摘要
var items = await orderRepository.GetItemsAsync(order.Id, order.TenantId, cancellationToken);
var itemsSummary = items.Count > 0
? string.Join("、", items.Take(3).Select(x => x.ProductName))
+ (items.Count > 3 ? $" 等{items.Count}件" : string.Empty)
: string.Empty;
// 9. 发布支付成功事件
await eventPublisher.PublishAsync(EventRoutingKeys.PaymentSucceeded, new PaymentSucceededEvent
{
OrderId = order.Id,
PaymentNo = paymentRecord.TradeNo ?? string.Empty,
Amount = request.Amount,
TenantId = order.TenantId,
StoreId = order.StoreId,
OrderNo = order.OrderNo,
Channel = (int)order.Channel,
DeliveryType = (int)order.DeliveryType,
CustomerName = order.CustomerName,
ItemsSummary = itemsSummary,
PaidAt = request.PaidAt
}, cancellationToken);
// 10. 发布订单状态变更事件(触发 SignalR 推送)
await eventPublisher.PublishAsync(EventRoutingKeys.OrderStatusChanged, new OrderStatusChangedEvent
{
OrderId = order.Id,
OrderNo = order.OrderNo,
TenantId = order.TenantId,
StoreId = order.StoreId,
OldStatus = (int)OrderStatus.PendingPayment,
NewStatus = (int)OrderStatus.AwaitingPreparation,
Channel = (int)order.Channel,
DeliveryType = (int)order.DeliveryType,
CustomerName = order.CustomerName,
ItemsSummary = itemsSummary,
PaidAmount = request.Amount,
OccurredAt = request.PaidAt
}, cancellationToken);
// 11. 记录日志
logger.LogInformation("支付回调成功 {OrderNo},金额 {Amount}", request.OrderNo, request.Amount);
return true;
}
}

View File

@@ -25,6 +25,36 @@ public sealed class PaymentSucceededEvent
/// </summary> /// </summary>
public long TenantId { get; init; } public long TenantId { get; init; }
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 订单编号。
/// </summary>
public string OrderNo { get; init; } = string.Empty;
/// <summary>
/// 下单渠道。
/// </summary>
public int Channel { get; init; }
/// <summary>
/// 履约类型。
/// </summary>
public int DeliveryType { get; init; }
/// <summary>
/// 顾客姓名。
/// </summary>
public string? CustomerName { get; init; }
/// <summary>
/// 商品摘要。
/// </summary>
public string? ItemsSummary { get; init; }
/// <summary> /// <summary>
/// 支付时间UTC /// 支付时间UTC
/// </summary> /// </summary>