feat: 添加订单超时自动取消与 SMS 告警任务
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s
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:
@@ -37,6 +37,11 @@ public static class SchedulerServiceCollectionExtensions
|
|||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddOptions<OrderTimeoutOptions>()
|
||||||
|
.Bind(configuration.GetSection("Scheduler:OrderTimeout"))
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
services.AddHangfire((serviceProvider, config) =>
|
services.AddHangfire((serviceProvider, config) =>
|
||||||
{
|
{
|
||||||
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
|
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
|
||||||
@@ -59,6 +64,7 @@ public static class SchedulerServiceCollectionExtensions
|
|||||||
services.AddHostedService<RecurringJobHostedService>();
|
services.AddHostedService<RecurringJobHostedService>();
|
||||||
|
|
||||||
services.AddScoped<OrderTimeoutJob>();
|
services.AddScoped<OrderTimeoutJob>();
|
||||||
|
services.AddScoped<OrderEscalationJob>();
|
||||||
services.AddScoped<CouponExpireJob>();
|
services.AddScoped<CouponExpireJob>();
|
||||||
services.AddScoped<LogCleanupJob>();
|
services.AddScoped<LogCleanupJob>();
|
||||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,92 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
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;
|
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 订单超时取消任务(占位,后续接入订单服务)。
|
/// 订单超时自动取消任务。
|
||||||
/// </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>
|
||||||
/// 执行超时订单检查。
|
/// 执行超时订单检查与自动取消。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task ExecuteAsync()
|
public async Task ExecuteAsync()
|
||||||
{
|
{
|
||||||
logger.LogInformation("定时任务:检查超时未支付订单并取消(占位实现)");
|
var config = options.CurrentValue;
|
||||||
return Task.CompletedTask;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public sealed class RecurringJobRegistrar(
|
|||||||
{
|
{
|
||||||
// 1. 业务占位任务(示例)
|
// 1. 业务占位任务(示例)
|
||||||
RecurringJob.AddOrUpdate<OrderTimeoutJob>("orders.timeout-cancel", job => job.ExecuteAsync(), "*/5 * * * *");
|
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<CouponExpireJob>("coupons.expire", job => job.ExecuteAsync(), "0 */1 * * *");
|
||||||
RecurringJob.AddOrUpdate<LogCleanupJob>("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *");
|
RecurringJob.AddOrUpdate<LogCleanupJob>("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user