diff --git a/src/Api/TakeoutSaaS.TenantApi/Consumers/PaymentSucceededConsumer.cs b/src/Api/TakeoutSaaS.TenantApi/Consumers/PaymentSucceededConsumer.cs new file mode 100644 index 0000000..6b26867 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Consumers/PaymentSucceededConsumer.cs @@ -0,0 +1,34 @@ +using MassTransit; +using Microsoft.AspNetCore.SignalR; +using TakeoutSaaS.Application.Messaging.Events; +using TakeoutSaaS.TenantApi.Hubs; + +namespace TakeoutSaaS.TenantApi.Consumers; + +/// +/// 支付成功事件消费者 — 推送新订单到看板。 +/// +public sealed class PaymentSucceededConsumer(IHubContext hubContext) + : IConsumer +{ + /// + public async Task Consume(ConsumeContext 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); + } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/PaymentCallbackController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/PaymentCallbackController.cs new file mode 100644 index 0000000..553021d --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/PaymentCallbackController.cs @@ -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; + +/// +/// 支付回调接口。 +/// +[ApiVersion("1.0")] +[Route("api/tenant/v{version:apiVersion}/payment/callback")] +public sealed class PaymentCallbackController( + IMediator mediator, + StoreContextService storeContextService) : BaseApiController +{ + /// + /// 微信支付回调(预留,签名验证后续接入 SDK)。 + /// + [HttpPost("wechat")] + [AllowAnonymous] + public async Task WeChatCallback(CancellationToken cancellationToken) + { + // TODO: 接入微信支付 V3 SDK 后实现签名验证与报文解析 + return Ok(new { code = "SUCCESS", message = "成功" }); + } + + /// + /// 支付宝回调(预留,签名验证后续接入 SDK)。 + /// + [HttpPost("alipay")] + [AllowAnonymous] + public async Task AlipayCallback(CancellationToken cancellationToken) + { + // TODO: 接入支付宝 RSA2 SDK 后实现签名验证与报文解析 + return Ok("success"); + } + + /// + /// 内部模拟支付回调(仅开发环境)。 + /// + [HttpPost("internal")] + [Authorize] + public async Task> 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.Ok(result); + } +} + +/// +/// 内部模拟支付回调请求体。 +/// +public sealed record InternalPaymentCallbackRequest +{ + /// + /// 订单号。 + /// + public required string OrderNo { get; init; } + + /// + /// 支付金额。 + /// + public required decimal Amount { get; init; } + + /// + /// 支付流水号(可选)。 + /// + public string? TransactionId { get; init; } + + /// + /// 支付方式(可选,默认微信)。 + /// + public PaymentMethod? Method { get; init; } + + /// + /// 支付时间(可选,默认当前时间)。 + /// + public DateTime? PaidAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Commands/ProcessPaymentCallbackCommand.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/ProcessPaymentCallbackCommand.cs new file mode 100644 index 0000000..b6bb1d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/ProcessPaymentCallbackCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Payments.Commands; + +/// +/// 支付回调处理命令。 +/// +public sealed record ProcessPaymentCallbackCommand : IRequest +{ + /// + /// 商户订单号。 + /// + public required string OrderNo { get; init; } + + /// + /// 第三方支付流水号。 + /// + public required string ChannelTransactionId { get; init; } + + /// + /// 支付方式。 + /// + public required PaymentMethod Method { get; init; } + + /// + /// 实付金额。 + /// + public required decimal Amount { get; init; } + + /// + /// 支付时间。 + /// + public required DateTime PaidAt { get; init; } + + /// + /// 租户标识。 + /// + public required long TenantId { get; init; } + + /// + /// 原始回调报文(留存)。 + /// + public string? RawPayload { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/ProcessPaymentCallbackCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/ProcessPaymentCallbackCommandHandler.cs new file mode 100644 index 0000000..81ae6a6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/ProcessPaymentCallbackCommandHandler.cs @@ -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; + +/// +/// 支付回调处理器。 +/// +public sealed class ProcessPaymentCallbackCommandHandler( + IOrderRepository orderRepository, + IPaymentRepository paymentRepository, + IEventPublisher eventPublisher, + IIdGenerator idGenerator, + ILogger logger) + : IRequestHandler +{ + /// + public async Task 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs b/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs index b0094f7..7a2dcfa 100644 --- a/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs +++ b/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs @@ -25,6 +25,36 @@ public sealed class PaymentSucceededEvent /// public long TenantId { get; init; } + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 订单编号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 下单渠道。 + /// + public int Channel { get; init; } + + /// + /// 履约类型。 + /// + public int DeliveryType { get; init; } + + /// + /// 顾客姓名。 + /// + public string? CustomerName { get; init; } + + /// + /// 商品摘要。 + /// + public string? ItemsSummary { get; init; } + /// /// 支付时间(UTC)。 ///