feat: 添加支付回调链路(支付成功 → 订单状态流转 → SignalR 推送)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 12s
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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user