diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs index 2d6ab08..0cb8c5b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ProcessOverdueBillingsCommandHandler.cs @@ -1,6 +1,6 @@ using MediatR; using TakeoutSaaS.Application.App.Billings.Commands; -using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Domain.Tenants.Services; namespace TakeoutSaaS.Application.App.Billings.Handlers; @@ -8,42 +8,13 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers; /// 处理逾期账单命令处理器(后台任务)。 /// public sealed class ProcessOverdueBillingsCommandHandler( - ITenantBillingRepository billingRepository) + IBillingDomainService billingDomainService) : IRequestHandler { /// public async Task Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken) { - // 1. 查询逾期账单(到期日已过且未支付) - var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken); - if (overdueBillings.Count == 0) - { - return 0; - } - - // 2. (空行后) 标记为逾期并更新通知时间 - var now = DateTime.UtcNow; - var updatedCount = 0; - foreach (var billing in overdueBillings) - { - var before = billing.Status; - billing.MarkAsOverdue(); - - if (before != billing.Status) - { - billing.OverdueNotifiedAt ??= now; - await billingRepository.UpdateAsync(billing, cancellationToken); - updatedCount++; - } - } - - // 3. (空行后) 持久化 - if (updatedCount > 0) - { - await billingRepository.SaveChangesAsync(cancellationToken); - } - - return updatedCount; + // 1. 委托领域服务执行逾期账单处理(Pending && DueDate < Now -> Overdue) + return await billingDomainService.ProcessOverdueBillingsAsync(cancellationToken); } } - diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs index ac3db16..0dc570b 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs @@ -102,14 +102,13 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena // 1. 以当前 UTC 时间作为逾期判断基准 var now = DateTime.UtcNow; - // 2. (空行后) 查询逾期且未结清/未取消的账单 + // 2. (空行后) 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue) return await context.TenantBillingStatements .IgnoreQueryFilters() .AsNoTracking() .Where(x => x.DeletedAt == null && x.DueDate < now - && x.Status != TenantBillingStatus.Paid - && x.Status != TenantBillingStatus.Cancelled) + && x.Status == TenantBillingStatus.Pending) .OrderBy(x => x.DueDate) .ToListAsync(cancellationToken); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs index 0cf1759..73b019e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs @@ -147,14 +147,14 @@ public sealed class BillingDomainService( /// public async Task ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default) { - // 1. 查询当前已逾期且未支付/未取消的账单(由仓储按 DueDate 筛选) + // 1. 查询当前已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选) var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken); if (overdueBillings.Count == 0) { return 0; } - // 2. (空行后) 批量标记逾期(仅处理 Pending) + // 2. (空行后) 批量标记逾期(防御性:再次判断 Pending) var processedAt = DateTime.UtcNow; var updated = 0; foreach (var billing in overdueBillings) diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs index 34bd320..337ec40 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs @@ -32,6 +32,11 @@ public static class SchedulerServiceCollectionExtensions .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .Bind(configuration.GetSection("Scheduler:BillingAutomation")) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddHangfire((serviceProvider, config) => { var options = serviceProvider.GetRequiredService>().CurrentValue; @@ -59,6 +64,7 @@ public static class SchedulerServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/BillingOverdueProcessJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/BillingOverdueProcessJob.cs new file mode 100644 index 0000000..e17a784 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/BillingOverdueProcessJob.cs @@ -0,0 +1,32 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Module.Scheduler.Options; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 账单逾期标记任务:将超过到期日的待支付账单标记为逾期。 +/// +public sealed class BillingOverdueProcessJob( + IMediator mediator, + IOptionsMonitor optionsMonitor, + ILogger logger) +{ + /// + /// 执行逾期账单标记。 + /// + public async Task ExecuteAsync() + { + // 1. 读取配置并执行逾期处理 + var options = optionsMonitor.CurrentValue; + var updatedCount = await mediator.Send(new ProcessOverdueBillingsCommand()); + + // 2. 记录执行结果 + logger.LogInformation( + "定时任务:逾期账单标记完成,更新 {UpdatedCount} 条(Cron={Cron})", + updatedCount, + options.OverdueBillingProcessCron); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Options/BillingAutomationOptions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/BillingAutomationOptions.cs new file mode 100644 index 0000000..2ced3bc --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/BillingAutomationOptions.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Scheduler.Options; + +/// +/// 账单自动化相关配置(逾期标记等)。 +/// +public sealed class BillingAutomationOptions +{ + /// + /// 逾期账单标记任务 Cron 表达式(Hangfire)。 + /// 默认每 10 分钟执行一次。 + /// + [Required] + public string OverdueBillingProcessCron { get; set; } = "*/10 * * * *"; +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs index 7dd98b2..71c90f0 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs @@ -9,7 +9,10 @@ namespace TakeoutSaaS.Module.Scheduler.Services; /// /// 周期性任务注册器。 /// -public sealed class RecurringJobRegistrar(IOptionsMonitor subscriptionAutomationOptions) : IRecurringJobRegistrar +public sealed class RecurringJobRegistrar( + IOptionsMonitor subscriptionAutomationOptions, + IOptionsMonitor billingAutomationOptions) + : IRecurringJobRegistrar { /// public Task RegisterAsync(CancellationToken cancellationToken = default) @@ -34,7 +37,13 @@ public sealed class RecurringJobRegistrar(IOptionsMonitor job.ExecuteAsync(), $"0 {options.SubscriptionExpiryCheckExecuteHourUtc} * * *"); + // 3. (空行后) 账单自动化任务(逾期标记) + var billingOptions = billingAutomationOptions.CurrentValue; + RecurringJob.AddOrUpdate( + "billings.overdue-process", + job => job.ExecuteAsync(), + billingOptions.OverdueBillingProcessCron); + return Task.CompletedTask; } } -