feat(billing): 新增逾期账单自动标记定时任务
1. 新增Hangfire定时任务BillingOverdueProcessJob 2. 修复逾期账单查询条件过宽问题 3. 默认每10分钟执行一次逾期检查
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
using TakeoutSaaS.Application.App.Billings.Commands;
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
using TakeoutSaaS.Domain.Tenants.Services;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||||
|
|
||||||
@@ -8,42 +8,13 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|||||||
/// 处理逾期账单命令处理器(后台任务)。
|
/// 处理逾期账单命令处理器(后台任务)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ProcessOverdueBillingsCommandHandler(
|
public sealed class ProcessOverdueBillingsCommandHandler(
|
||||||
ITenantBillingRepository billingRepository)
|
IBillingDomainService billingDomainService)
|
||||||
: IRequestHandler<ProcessOverdueBillingsCommand, int>
|
: IRequestHandler<ProcessOverdueBillingsCommand, int>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken)
|
public async Task<int> Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. 查询逾期账单(到期日已过且未支付)
|
// 1. 委托领域服务执行逾期账单处理(Pending && DueDate < Now -> Overdue)
|
||||||
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
|
return await billingDomainService.ProcessOverdueBillingsAsync(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,14 +102,13 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
|||||||
// 1. 以当前 UTC 时间作为逾期判断基准
|
// 1. 以当前 UTC 时间作为逾期判断基准
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
// 2. (空行后) 查询逾期且未结清/未取消的账单
|
// 2. (空行后) 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue)
|
||||||
return await context.TenantBillingStatements
|
return await context.TenantBillingStatements
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.DeletedAt == null
|
.Where(x => x.DeletedAt == null
|
||||||
&& x.DueDate < now
|
&& x.DueDate < now
|
||||||
&& x.Status != TenantBillingStatus.Paid
|
&& x.Status == TenantBillingStatus.Pending)
|
||||||
&& x.Status != TenantBillingStatus.Cancelled)
|
|
||||||
.OrderBy(x => x.DueDate)
|
.OrderBy(x => x.DueDate)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,14 +147,14 @@ public sealed class BillingDomainService(
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
|
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 查询当前已逾期且未支付/未取消的账单(由仓储按 DueDate 筛选)
|
// 1. 查询当前已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
|
||||||
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
|
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
|
||||||
if (overdueBillings.Count == 0)
|
if (overdueBillings.Count == 0)
|
||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. (空行后) 批量标记逾期(仅处理 Pending)
|
// 2. (空行后) 批量标记逾期(防御性:再次判断 Pending)
|
||||||
var processedAt = DateTime.UtcNow;
|
var processedAt = DateTime.UtcNow;
|
||||||
var updated = 0;
|
var updated = 0;
|
||||||
foreach (var billing in overdueBillings)
|
foreach (var billing in overdueBillings)
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public static class SchedulerServiceCollectionExtensions
|
|||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddOptions<BillingAutomationOptions>()
|
||||||
|
.Bind(configuration.GetSection("Scheduler:BillingAutomation"))
|
||||||
|
.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.AddScoped<SubscriptionRenewalReminderJob>();
|
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||||
services.AddScoped<SubscriptionExpiryCheckJob>();
|
services.AddScoped<SubscriptionExpiryCheckJob>();
|
||||||
services.AddScoped<SubscriptionAutoRenewalJob>();
|
services.AddScoped<SubscriptionAutoRenewalJob>();
|
||||||
|
services.AddScoped<BillingOverdueProcessJob>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 账单逾期标记任务:将超过到期日的待支付账单标记为逾期。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BillingOverdueProcessJob(
|
||||||
|
IMediator mediator,
|
||||||
|
IOptionsMonitor<BillingAutomationOptions> optionsMonitor,
|
||||||
|
ILogger<BillingOverdueProcessJob> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 执行逾期账单标记。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Module.Scheduler.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 账单自动化相关配置(逾期标记等)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BillingAutomationOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 逾期账单标记任务 Cron 表达式(Hangfire)。
|
||||||
|
/// 默认每 10 分钟执行一次。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string OverdueBillingProcessCron { get; set; } = "*/10 * * * *";
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ namespace TakeoutSaaS.Module.Scheduler.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 周期性任务注册器。
|
/// 周期性任务注册器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RecurringJobRegistrar(IOptionsMonitor<SubscriptionAutomationOptions> subscriptionAutomationOptions) : IRecurringJobRegistrar
|
public sealed class RecurringJobRegistrar(
|
||||||
|
IOptionsMonitor<SubscriptionAutomationOptions> subscriptionAutomationOptions,
|
||||||
|
IOptionsMonitor<BillingAutomationOptions> billingAutomationOptions)
|
||||||
|
: IRecurringJobRegistrar
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task RegisterAsync(CancellationToken cancellationToken = default)
|
public Task RegisterAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -34,7 +37,13 @@ public sealed class RecurringJobRegistrar(IOptionsMonitor<SubscriptionAutomation
|
|||||||
job => job.ExecuteAsync(),
|
job => job.ExecuteAsync(),
|
||||||
$"0 {options.SubscriptionExpiryCheckExecuteHourUtc} * * *");
|
$"0 {options.SubscriptionExpiryCheckExecuteHourUtc} * * *");
|
||||||
|
|
||||||
|
// 3. (空行后) 账单自动化任务(逾期标记)
|
||||||
|
var billingOptions = billingAutomationOptions.CurrentValue;
|
||||||
|
RecurringJob.AddOrUpdate<BillingOverdueProcessJob>(
|
||||||
|
"billings.overdue-process",
|
||||||
|
job => job.ExecuteAsync(),
|
||||||
|
billingOptions.OverdueBillingProcessCron);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user