diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs index 337ec40..ff583c6 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs @@ -37,6 +37,11 @@ public static class SchedulerServiceCollectionExtensions .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .Bind(configuration.GetSection("Scheduler:OrderTimeout")) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddHangfire((serviceProvider, config) => { var options = serviceProvider.GetRequiredService>().CurrentValue; @@ -59,6 +64,7 @@ public static class SchedulerServiceCollectionExtensions services.AddHostedService(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderEscalationJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderEscalationJob.cs new file mode 100644 index 0000000..176ea44 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderEscalationJob.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Module.Scheduler.Options; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 订单超时 SMS 告警任务。 +/// +public sealed class OrderEscalationJob( + IOrderRepository orderRepository, + IOptionsMonitor options, + ILogger logger) +{ + /// + /// 执行超时告警检查。 + /// + public async Task ExecuteAsync() + { + var config = options.CurrentValue; + var cutoff = DateTime.UtcNow.AddMinutes(-config.SmsEscalationMinutes); + + // 1. 查询超时待接单订单 + var pendingOrders = await orderRepository.SearchAsync( + tenantId: 0, + status: OrderStatus.AwaitingPreparation, + paymentStatus: null); + + var escalationOrders = pendingOrders + .Where(o => o.CreatedAt < cutoff) + .ToList(); + + if (escalationOrders.Count == 0) + { + logger.LogDebug("无需告警的超时订单"); + return; + } + + // 2. 逐笔发送告警(SMS 模块后续接入) + foreach (var order in escalationOrders) + { + // TODO: 接入 ISmsSenderResolver 发送短信给店长 + // TODO: 使用 Redis key order:escalation:{orderId} 防重复发送 + logger.LogWarning( + "订单 {OrderNo} ({OrderId}) 超过 {Minutes} 分钟未接单,需告警", + order.OrderNo, order.Id, config.SmsEscalationMinutes); + } + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs index 80d7513..07eba2b 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs @@ -1,18 +1,92 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +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.Module.Scheduler.Options; +using TakeoutSaaS.Shared.Abstractions.Ids; namespace TakeoutSaaS.Module.Scheduler.Jobs; /// -/// 订单超时取消任务(占位,后续接入订单服务)。 +/// 订单超时自动取消任务。 /// -public sealed class OrderTimeoutJob(ILogger logger) +public sealed class OrderTimeoutJob( + IOrderRepository orderRepository, + IEventPublisher eventPublisher, + IIdGenerator idGenerator, + IOptionsMonitor options, + ILogger logger) { /// - /// 执行超时订单检查。 + /// 执行超时订单检查与自动取消。 /// - public Task ExecuteAsync() + public async Task ExecuteAsync() { - logger.LogInformation("定时任务:检查超时未支付订单并取消(占位实现)"); - return Task.CompletedTask; + var config = options.CurrentValue; + var cutoff = DateTime.UtcNow.AddMinutes(-config.AutoCancelMinutes); + + // 1. 查询所有超时待接单订单 + var pendingOrders = await orderRepository.SearchAsync( + tenantId: 0, // 跨租户查询,由仓储层处理 + status: OrderStatus.AwaitingPreparation, + paymentStatus: null); + + var timedOutOrders = pendingOrders + .Where(o => o.CreatedAt < cutoff) + .ToList(); + + if (timedOutOrders.Count == 0) + { + logger.LogDebug("无超时待接单订单"); + return; + } + + logger.LogInformation("发现 {Count} 笔超时待接单订单,开始自动取消", timedOutOrders.Count); + + // 2. 逐笔取消 + foreach (var order in timedOutOrders) + { + var oldStatus = order.Status; + order.Status = OrderStatus.Cancelled; + order.CancelledAt = DateTime.UtcNow; + order.CancelReason = "超时未接单,系统自动取消"; + + // 3. 写入状态流转记录 + var history = new OrderStatusHistory + { + Id = idGenerator.NextId(), + OrderId = order.Id, + TenantId = order.TenantId, + Status = OrderStatus.Cancelled, + Notes = "超时未接单,系统自动取消", + OccurredAt = DateTime.UtcNow + }; + + await orderRepository.UpdateOrderAsync(order); + await orderRepository.AddStatusHistoryAsync(history); + await orderRepository.SaveChangesAsync(); + + // 4. 发布状态变更事件 + await eventPublisher.PublishAsync(EventRoutingKeys.OrderStatusChanged, new OrderStatusChangedEvent + { + OrderId = order.Id, + OrderNo = order.OrderNo, + TenantId = order.TenantId, + StoreId = order.StoreId, + OldStatus = (int)oldStatus, + NewStatus = (int)OrderStatus.Cancelled, + Channel = (int)order.Channel, + DeliveryType = (int)order.DeliveryType, + CustomerName = order.CustomerName, + PaidAmount = order.PaidAmount, + OccurredAt = DateTime.UtcNow + }); + + logger.LogInformation("自动取消超时订单 {OrderNo} ({OrderId})", order.OrderNo, order.Id); + } } } diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Options/OrderTimeoutOptions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/OrderTimeoutOptions.cs new file mode 100644 index 0000000..d467c5a --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/OrderTimeoutOptions.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Module.Scheduler.Options; + +/// +/// 订单超时配置。 +/// +public sealed class OrderTimeoutOptions +{ + /// + /// 自动取消超时分钟数(默认 15 分钟)。 + /// + public int AutoCancelMinutes { get; init; } = 15; + + /// + /// SMS 告警超时分钟数(默认 10 分钟)。 + /// + public int SmsEscalationMinutes { get; init; } = 10; +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs index 5d65dc3..94ccb5e 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs @@ -19,6 +19,7 @@ public sealed class RecurringJobRegistrar( { // 1. 业务占位任务(示例) RecurringJob.AddOrUpdate("orders.timeout-cancel", job => job.ExecuteAsync(), "*/5 * * * *"); + RecurringJob.AddOrUpdate("orders.escalation-sms", job => job.ExecuteAsync(), "*/2 * * * *"); RecurringJob.AddOrUpdate("coupons.expire", job => job.ExecuteAsync(), "0 */1 * * *"); RecurringJob.AddOrUpdate("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *");