feat: 添加订单超时自动取消与 SMS 告警任务
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s

- OrderTimeoutJob 替换占位实现为真实逻辑(查询超时订单 → 批量取消 → 发布事件)
- 新增 OrderEscalationJob(每 2 分钟检查超时未接单订单,预留 SMS 告警)
- 新增 OrderTimeoutOptions(AutoCancelMinutes=15, SmsEscalationMinutes=10)
- RecurringJobRegistrar 注册 OrderEscalationJob
- SchedulerServiceCollectionExtensions 注册 Job + Options
This commit is contained in:
2026-02-27 13:11:01 +08:00
parent 3c423f87d4
commit 04e76cd519
5 changed files with 155 additions and 6 deletions

View File

@@ -37,6 +37,11 @@ public static class SchedulerServiceCollectionExtensions
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<OrderTimeoutOptions>()
.Bind(configuration.GetSection("Scheduler:OrderTimeout"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddHangfire((serviceProvider, config) =>
{
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
@@ -59,6 +64,7 @@ public static class SchedulerServiceCollectionExtensions
services.AddHostedService<RecurringJobHostedService>();
services.AddScoped<OrderTimeoutJob>();
services.AddScoped<OrderEscalationJob>();
services.AddScoped<CouponExpireJob>();
services.AddScoped<LogCleanupJob>();
services.AddScoped<SubscriptionRenewalReminderJob>();

View File

@@ -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;
/// <summary>
/// 订单超时 SMS 告警任务。
/// </summary>
public sealed class OrderEscalationJob(
IOrderRepository orderRepository,
IOptionsMonitor<OrderTimeoutOptions> options,
ILogger<OrderEscalationJob> logger)
{
/// <summary>
/// 执行超时告警检查。
/// </summary>
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);
}
}
}

View File

@@ -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;
/// <summary>
/// 订单超时取消任务(占位,后续接入订单服务)
/// 订单超时自动取消任务。
/// </summary>
public sealed class OrderTimeoutJob(ILogger<OrderTimeoutJob> logger)
public sealed class OrderTimeoutJob(
IOrderRepository orderRepository,
IEventPublisher eventPublisher,
IIdGenerator idGenerator,
IOptionsMonitor<OrderTimeoutOptions> options,
ILogger<OrderTimeoutJob> logger)
{
/// <summary>
/// 执行超时订单检查。
/// 执行超时订单检查与自动取消
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Module.Scheduler.Options;
/// <summary>
/// 订单超时配置。
/// </summary>
public sealed class OrderTimeoutOptions
{
/// <summary>
/// 自动取消超时分钟数(默认 15 分钟)。
/// </summary>
public int AutoCancelMinutes { get; init; } = 15;
/// <summary>
/// SMS 告警超时分钟数(默认 10 分钟)。
/// </summary>
public int SmsEscalationMinutes { get; init; } = 10;
}

View File

@@ -19,6 +19,7 @@ public sealed class RecurringJobRegistrar(
{
// 1. 业务占位任务(示例)
RecurringJob.AddOrUpdate<OrderTimeoutJob>("orders.timeout-cancel", job => job.ExecuteAsync(), "*/5 * * * *");
RecurringJob.AddOrUpdate<OrderEscalationJob>("orders.escalation-sms", job => job.ExecuteAsync(), "*/2 * * * *");
RecurringJob.AddOrUpdate<CouponExpireJob>("coupons.expire", job => job.ExecuteAsync(), "0 */1 * * *");
RecurringJob.AddOrUpdate<LogCleanupJob>("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *");