feat: 实现订阅自动化定时任务系统

新增定时任务 (Scheduler Module):
- SubscriptionAutoRenewalJob: 自动续费账单生成
- SubscriptionRenewalReminderJob: 续费提醒发送 (7/3/1天)
- SubscriptionExpiryCheckJob: 到期检查与宽限期处理

新增 Command/Handler:
- ProcessAutoRenewalCommand: 处理自动续费逻辑
- ProcessRenewalRemindersCommand: 处理续费提醒逻辑
- ProcessSubscriptionExpiryCommand: 处理订阅到期逻辑

配置项 (SubscriptionAutomationOptions):
- AutoRenewalDaysBeforeExpiry: 到期前N天生成续费账单
- ReminderDaysBeforeExpiry: 提醒天数数组
- GracePeriodDays: 宽限期天数
- 各任务执行小时配置

Repository 增强:
- ISubscriptionRepository: 新增自动化查询方法
- ITenantBillingRepository: 新增账单创建方法
- ITenantNotificationRepository: 新增通知创建方法

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-17 21:06:01 +08:00
parent ab59e2e3e2
commit 98f49ea7ad
19 changed files with 815 additions and 2 deletions

View File

@@ -0,0 +1,33 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 处理自动续费:为开启自动续费且即将到期的订阅生成续费账单。
/// </summary>
public sealed record ProcessAutoRenewalCommand : IRequest<ProcessAutoRenewalResult>
{
/// <summary>
/// 到期前 N 天生成续费账单。
/// </summary>
[Range(0, 365, ErrorMessage = "续费提前天数必须在 0~365 之间")]
public int RenewalDaysBeforeExpiry { get; init; } = 3;
}
/// <summary>
/// 自动续费处理结果。
/// </summary>
public sealed record ProcessAutoRenewalResult
{
/// <summary>
/// 扫描到的候选订阅数量。
/// </summary>
public int CandidateCount { get; init; }
/// <summary>
/// 实际创建的账单数量。
/// </summary>
public int CreatedBillCount { get; init; }
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 处理续费提醒:按到期前指定天数批量创建站内提醒通知(幂等)。
/// </summary>
public sealed record ProcessRenewalRemindersCommand : IRequest<ProcessRenewalRemindersResult>
{
/// <summary>
/// 提醒时间点(到期前 N 天)。
/// </summary>
[MinLength(1, ErrorMessage = "至少需要配置一个提醒时间点")]
public IReadOnlyList<int> ReminderDaysBeforeExpiry { get; init; } = [7, 3, 1];
}
/// <summary>
/// 续费提醒处理结果。
/// </summary>
public sealed record ProcessRenewalRemindersResult
{
/// <summary>
/// 扫描到的候选订阅数量。
/// </summary>
public int CandidateCount { get; init; }
/// <summary>
/// 实际创建的提醒数量。
/// </summary>
public int CreatedReminderCount { get; init; }
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 处理订阅到期:到期进入宽限期,宽限期结束自动暂停。
/// </summary>
public sealed record ProcessSubscriptionExpiryCommand : IRequest<ProcessSubscriptionExpiryResult>
{
/// <summary>
/// 宽限期天数。
/// </summary>
[Range(0, 365, ErrorMessage = "宽限期天数必须在 0~365 之间")]
public int GracePeriodDays { get; init; } = 7;
}
/// <summary>
/// 订阅到期处理结果。
/// </summary>
public sealed record ProcessSubscriptionExpiryResult
{
/// <summary>
/// 从 Active 进入宽限期的数量。
/// </summary>
public int EnteredGracePeriodCount { get; init; }
/// <summary>
/// 宽限期到期并暂停的数量。
/// </summary>
public int SuspendedCount { get; init; }
}

View File

@@ -0,0 +1,135 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 自动续费处理器:生成续费账单(幂等)。
/// </summary>
public sealed class ProcessAutoRenewalCommandHandler(
ISubscriptionRepository subscriptionRepository,
ITenantBillingRepository billingRepository,
IIdGenerator idGenerator,
ILogger<ProcessAutoRenewalCommandHandler> logger)
: IRequestHandler<ProcessAutoRenewalCommand, ProcessAutoRenewalResult>
{
/// <inheritdoc />
public async Task<ProcessAutoRenewalResult> Handle(ProcessAutoRenewalCommand request, CancellationToken cancellationToken)
{
// 1. 计算续费阈值时间
var now = DateTime.UtcNow;
var renewalThreshold = now.AddDays(request.RenewalDaysBeforeExpiry);
// 2. 查询候选订阅(含套餐)
var candidates = await subscriptionRepository.FindAutoRenewalCandidatesAsync(now, renewalThreshold, cancellationToken);
var createdBillCount = 0;
// 3. 遍历候选订阅,生成账单
foreach (var candidate in candidates)
{
// 3.1 幂等校验:同一周期开始时间只允许存在一张未取消账单
var periodStart = candidate.Subscription.EffectiveTo;
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
candidate.Subscription.TenantId,
periodStart,
cancellationToken);
if (exists)
{
logger.LogInformation(
"自动续费:租户 {TenantId} 订阅 {SubscriptionId} 已存在周期 {PeriodStart} 的续费账单,跳过",
candidate.Subscription.TenantId,
candidate.Subscription.Id,
periodStart);
continue;
}
// 3.2 计算续费周期(月)
var durationMonths = CalculateDurationMonths(candidate.Subscription.EffectiveFrom, candidate.Subscription.EffectiveTo);
if (durationMonths <= 0)
{
durationMonths = 1;
}
// 3.3 计算账单周期与金额
var periodEnd = periodStart.AddMonths(durationMonths);
var amountDue = CalculateRenewalAmount(candidate.Package, durationMonths);
// 3.4 生成账单Pending
var statementNo = $"BILL-{now:yyyyMMddHHmmss}-{candidate.Subscription.TenantId}-{candidate.Subscription.Id}";
var lineItemsJson = JsonSerializer.Serialize(new
{
PackageName = candidate.Package.Name,
RenewalMonths = durationMonths,
SubscriptionId = candidate.Subscription.Id
});
await billingRepository.AddAsync(new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = candidate.Subscription.TenantId,
StatementNo = statementNo,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
AmountDue = amountDue,
AmountPaid = 0,
DueDate = periodStart.AddDays(-1),
LineItemsJson = lineItemsJson,
CreatedAt = now
}, cancellationToken);
createdBillCount++;
logger.LogInformation(
"自动续费:为租户 {TenantId} 订阅 {SubscriptionId} 生成账单 {StatementNo},金额 {AmountDue},周期 {PeriodStart}~{PeriodEnd}",
candidate.Subscription.TenantId,
candidate.Subscription.Id,
statementNo,
amountDue,
periodStart,
periodEnd);
}
// 4. 保存账单变更
if (createdBillCount > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
return new ProcessAutoRenewalResult
{
CandidateCount = candidates.Count,
CreatedBillCount = createdBillCount
};
}
private static int CalculateDurationMonths(DateTime effectiveFrom, DateTime effectiveTo)
{
// 1. 以年月差作为周期(月),兼容“按月续费”模型
var months = (effectiveTo.Year - effectiveFrom.Year) * 12 + effectiveTo.Month - effectiveFrom.Month;
// 2. (空行后) 对不足 1 个月的情况兜底为 1
return months <= 0 ? 1 : months;
}
private static decimal CalculateRenewalAmount(TenantPackage package, int durationMonths)
{
// 1. 优先使用年付价(按整年计费),剩余月份按月付价补齐
var monthlyPrice = package.MonthlyPrice ?? 0m;
var yearlyPrice = package.YearlyPrice;
if (yearlyPrice is null || durationMonths < 12)
{
return monthlyPrice * durationMonths;
}
// 2. (空行后) 按年 + 月组合计算金额
var years = durationMonths / 12;
var remainingMonths = durationMonths % 12;
return yearlyPrice.Value * years + monthlyPrice * remainingMonths;
}
}

View File

@@ -0,0 +1,115 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 续费提醒处理器:在到期前 7/3/1 天等时间点发送站内提醒(幂等)。
/// </summary>
public sealed class ProcessRenewalRemindersCommandHandler(
ISubscriptionRepository subscriptionRepository,
ITenantNotificationRepository notificationRepository,
IIdGenerator idGenerator,
ILogger<ProcessRenewalRemindersCommandHandler> logger)
: IRequestHandler<ProcessRenewalRemindersCommand, ProcessRenewalRemindersResult>
{
private const string ReminderTitle = "订阅续费提醒";
/// <inheritdoc />
public async Task<ProcessRenewalRemindersResult> Handle(ProcessRenewalRemindersCommand request, CancellationToken cancellationToken)
{
// 1. 读取提醒配置
var now = DateTime.UtcNow;
var candidateCount = 0;
var createdReminderCount = 0;
var dedupeWindowStart = now.AddHours(-24);
// 2. 按提醒时间点扫描到期订阅
foreach (var daysBeforeExpiry in request.ReminderDaysBeforeExpiry.Distinct().OrderByDescending(x => x))
{
// 2.1 计算目标日期区间(按天匹配)
var targetDate = now.AddDays(daysBeforeExpiry);
var startOfDay = targetDate.Date;
var endOfDay = startOfDay.AddDays(1);
// 2.2 查询候选订阅(活跃 + 未开自动续费 + 到期在当日)
var candidates = await subscriptionRepository.FindRenewalReminderCandidatesAsync(startOfDay, endOfDay, cancellationToken);
candidateCount += candidates.Count;
foreach (var item in candidates)
{
// 2.3 幂等:同一订阅 + 同一天数提醒,在 24 小时内只发送一次
var metadataJson = BuildReminderMetadata(item.Subscription.Id, daysBeforeExpiry, item.Subscription.EffectiveTo);
var alreadySent = await notificationRepository.ExistsByMetadataAsync(
item.Subscription.TenantId,
ReminderTitle,
metadataJson,
dedupeWindowStart,
cancellationToken);
if (alreadySent)
{
continue;
}
// 2.4 构造提醒内容并入库
var notification = new TenantNotification
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
Title = ReminderTitle,
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
Severity = daysBeforeExpiry <= 1
? TenantNotificationSeverity.Critical
: TenantNotificationSeverity.Warning,
Channel = TenantNotificationChannel.InApp,
SentAt = now,
ReadAt = null,
MetadataJson = metadataJson,
CreatedAt = now
};
await notificationRepository.AddAsync(notification, cancellationToken);
createdReminderCount++;
logger.LogInformation(
"续费提醒TenantId={TenantId}, TenantName={TenantName}, SubscriptionId={SubscriptionId}, DaysBeforeExpiry={DaysBeforeExpiry}",
item.Tenant.Id,
item.Tenant.Name,
item.Subscription.Id,
daysBeforeExpiry);
}
}
// 3. 保存变更
if (createdReminderCount > 0)
{
await notificationRepository.SaveChangesAsync(cancellationToken);
}
return new ProcessRenewalRemindersResult
{
CandidateCount = candidateCount,
CreatedReminderCount = createdReminderCount
};
}
private static string BuildReminderMetadata(long subscriptionId, int daysBeforeExpiry, DateTime effectiveTo)
{
// 1. 使用稳定字段顺序的 JSON 作为幂等键
return JsonSerializer.Serialize(new
{
Type = "RenewalReminder",
SubscriptionId = subscriptionId,
DaysBeforeExpiry = daysBeforeExpiry,
EffectiveTo = effectiveTo.ToString("O")
});
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 订阅到期处理器:自动进入宽限期并在宽限期结束后暂停。
/// </summary>
public sealed class ProcessSubscriptionExpiryCommandHandler(
ISubscriptionRepository subscriptionRepository,
ILogger<ProcessSubscriptionExpiryCommandHandler> logger)
: IRequestHandler<ProcessSubscriptionExpiryCommand, ProcessSubscriptionExpiryResult>
{
/// <inheritdoc />
public async Task<ProcessSubscriptionExpiryResult> Handle(ProcessSubscriptionExpiryCommand request, CancellationToken cancellationToken)
{
// 1. 查询到期订阅
var now = DateTime.UtcNow;
var expiredActive = await subscriptionRepository.FindExpiredActiveSubscriptionsAsync(now, cancellationToken);
var gracePeriodExpired = await subscriptionRepository.FindGracePeriodExpiredSubscriptionsAsync(now, request.GracePeriodDays, cancellationToken);
// 2. 更新订阅状态
foreach (var subscription in expiredActive)
{
subscription.Status = SubscriptionStatus.GracePeriod;
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
}
// 3. (空行后) 宽限期到期自动暂停
foreach (var subscription in gracePeriodExpired)
{
subscription.Status = SubscriptionStatus.Suspended;
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
}
// 4. (空行后) 保存变更
var totalChanged = expiredActive.Count + gracePeriodExpired.Count;
if (totalChanged > 0)
{
await subscriptionRepository.SaveChangesAsync(cancellationToken);
}
logger.LogInformation(
"订阅到期处理完成:进入宽限期 {EnteredGracePeriodCount},暂停 {SuspendedCount},宽限期天数 {GracePeriodDays}",
expiredActive.Count,
gracePeriodExpired.Count,
request.GracePeriodDays);
return new ProcessSubscriptionExpiryResult
{
EnteredGracePeriodCount = expiredActive.Count,
SuspendedCount = gracePeriodExpired.Count
};
}
}

View File

@@ -56,6 +56,52 @@ public interface ISubscriptionRepository
IEnumerable<long> subscriptionIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询自动续费候选订阅(活跃 + 开启自动续费 + 即将到期)。
/// </summary>
/// <param name="now">当前时间UTC。</param>
/// <param name="renewalThreshold">续费阈值时间UTC到期时间小于等于该时间视为候选。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>候选订阅集合(含套餐信息)。</returns>
Task<IReadOnlyList<AutoRenewalCandidate>> FindAutoRenewalCandidatesAsync(
DateTime now,
DateTime renewalThreshold,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询续费提醒候选订阅(活跃 + 未开启自动续费 + 到期时间落在指定日期范围)。
/// </summary>
/// <param name="startOfDay">筛选开始时间UTC。</param>
/// <param name="endOfDay">筛选结束时间UTC不含。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>候选订阅集合(含租户与套餐信息)。</returns>
Task<IReadOnlyList<RenewalReminderCandidate>> FindRenewalReminderCandidatesAsync(
DateTime startOfDay,
DateTime endOfDay,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询已到期仍处于 Active 的订阅(用于进入宽限期)。
/// </summary>
/// <param name="now">当前时间UTC。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>到期订阅集合。</returns>
Task<IReadOnlyList<TenantSubscription>> FindExpiredActiveSubscriptionsAsync(
DateTime now,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询宽限期已结束的订阅(用于自动暂停)。
/// </summary>
/// <param name="now">当前时间UTC。</param>
/// <param name="gracePeriodDays">宽限期天数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>宽限期到期订阅集合。</returns>
Task<IReadOnlyList<TenantSubscription>> FindGracePeriodExpiredSubscriptionsAsync(
DateTime now,
int gracePeriodDays,
CancellationToken cancellationToken = default);
#endregion
#region
@@ -271,6 +317,43 @@ public record SubscriptionWithTenant
public required Tenant Tenant { get; init; }
}
/// <summary>
/// 自动续费候选订阅信息。
/// </summary>
public sealed record AutoRenewalCandidate
{
/// <summary>
/// 订阅实体。
/// </summary>
public required TenantSubscription Subscription { get; init; }
/// <summary>
/// 当前套餐实体。
/// </summary>
public required TenantPackage Package { get; init; }
}
/// <summary>
/// 续费提醒候选订阅信息。
/// </summary>
public sealed record RenewalReminderCandidate
{
/// <summary>
/// 订阅实体。
/// </summary>
public required TenantSubscription Subscription { get; init; }
/// <summary>
/// 租户实体。
/// </summary>
public required Tenant Tenant { get; init; }
/// <summary>
/// 当前套餐实体。
/// </summary>
public required TenantPackage Package { get; init; }
}
/// <summary>
/// 订阅历史(含套餐名称)。
/// </summary>

View File

@@ -42,6 +42,18 @@ public interface ITenantBillingRepository
/// <returns>账单实体或 null。</returns>
Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default);
/// <summary>
/// 判断是否已存在指定周期开始时间的未取消账单(用于自动续费幂等)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="periodStart">账单周期开始时间UTC。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true否则 false。</returns>
Task<bool> ExistsNotCancelledByPeriodStartAsync(
long tenantId,
DateTime periodStart,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增账单。
/// </summary>

View File

@@ -35,6 +35,22 @@ public interface ITenantNotificationRepository
/// <returns>通知实体或 null。</returns>
Task<TenantNotification?> FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default);
/// <summary>
/// 判断是否已发送过指定元数据的通知(用于幂等控制)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="title">通知标题。</param>
/// <param name="metadataJson">元数据 JSON需与写入值完全一致。</param>
/// <param name="sentAfter">只在该时间之后发送的记录范围内判断UTC。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true否则 false。</returns>
Task<bool> ExistsByMetadataAsync(
long tenantId,
string title,
string metadataJson,
DateTime sentAfter,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增通知。
/// </summary>

View File

@@ -165,6 +165,88 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AutoRenewalCandidate>> FindAutoRenewalCandidatesAsync(
DateTime now,
DateTime renewalThreshold,
CancellationToken cancellationToken = default)
{
// 1. 查询开启自动续费且即将到期的活跃订阅
var query = dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& s.AutoRenew
&& s.EffectiveTo <= renewalThreshold
&& s.EffectiveTo > now)
.Join(
dbContext.TenantPackages,
subscription => subscription.TenantPackageId,
package => package.Id,
(subscription, package) => new AutoRenewalCandidate
{
Subscription = subscription,
Package = package
});
// 2. 返回候选列表
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RenewalReminderCandidate>> FindRenewalReminderCandidatesAsync(
DateTime startOfDay,
DateTime endOfDay,
CancellationToken cancellationToken = default)
{
// 1. 查询到期落在指定区间的订阅(且未开启自动续费)
var query = dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& !s.AutoRenew
&& s.EffectiveTo >= startOfDay
&& s.EffectiveTo < endOfDay)
.Join(
dbContext.Tenants,
subscription => subscription.TenantId,
tenant => tenant.Id,
(subscription, tenant) => new { Subscription = subscription, Tenant = tenant })
.Join(
dbContext.TenantPackages,
combined => combined.Subscription.TenantPackageId,
package => package.Id,
(combined, package) => new RenewalReminderCandidate
{
Subscription = combined.Subscription,
Tenant = combined.Tenant,
Package = package
});
// 2. 返回候选列表
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> FindExpiredActiveSubscriptionsAsync(
DateTime now,
CancellationToken cancellationToken = default)
{
// 1. 查询已到期仍为 Active 的订阅
return await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> FindGracePeriodExpiredSubscriptionsAsync(
DateTime now,
int gracePeriodDays,
CancellationToken cancellationToken = default)
{
// 1. 查询宽限期已结束的订阅
return await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.GracePeriod
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
.ToListAsync(cancellationToken);
}
#endregion
#region

View File

@@ -120,6 +120,20 @@ public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITe
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsNotCancelledByPeriodStartAsync(
long tenantId,
DateTime periodStart,
CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AsNoTracking()
.AnyAsync(
x => x.TenantId == tenantId
&& x.PeriodStart == periodStart
&& x.Status != TenantBillingStatus.Cancelled,
cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{

View File

@@ -56,6 +56,23 @@ public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context)
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsByMetadataAsync(
long tenantId,
string title,
string metadataJson,
DateTime sentAfter,
CancellationToken cancellationToken = default)
{
return context.TenantNotifications.AsNoTracking()
.AnyAsync(
x => x.TenantId == tenantId
&& x.Title == title
&& x.MetadataJson == metadataJson
&& x.SentAt >= sentAfter,
cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default)
{

View File

@@ -27,6 +27,11 @@ public static class SchedulerServiceCollectionExtensions
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<SubscriptionAutomationOptions>()
.Bind(configuration.GetSection("Scheduler:SubscriptionAutomation"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddHangfire((serviceProvider, config) =>
{
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerOptions>>().CurrentValue;
@@ -51,6 +56,9 @@ public static class SchedulerServiceCollectionExtensions
services.AddScoped<OrderTimeoutJob>();
services.AddScoped<CouponExpireJob>();
services.AddScoped<LogCleanupJob>();
services.AddScoped<SubscriptionRenewalReminderJob>();
services.AddScoped<SubscriptionExpiryCheckJob>();
services.AddScoped<SubscriptionAutoRenewalJob>();
return services;
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Module.Scheduler.Options;
namespace TakeoutSaaS.Module.Scheduler.Jobs;
/// <summary>
/// 订阅自动续费任务:为即将到期且开启自动续费的订阅生成续费账单。
/// </summary>
public sealed class SubscriptionAutoRenewalJob(
IMediator mediator,
IOptionsMonitor<SubscriptionAutomationOptions> optionsMonitor,
ILogger<SubscriptionAutoRenewalJob> logger)
{
/// <summary>
/// 执行自动续费账单生成。
/// </summary>
public async Task ExecuteAsync()
{
// 1. 读取配置并执行自动续费
var options = optionsMonitor.CurrentValue;
var result = await mediator.Send(new ProcessAutoRenewalCommand
{
RenewalDaysBeforeExpiry = options.AutoRenewalDaysBeforeExpiry
});
// 2. 记录执行结果
logger.LogInformation(
"定时任务:自动续费处理完成,候选 {CandidateCount},创建账单 {CreatedBillCount}",
result.CandidateCount,
result.CreatedBillCount);
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Module.Scheduler.Options;
namespace TakeoutSaaS.Module.Scheduler.Jobs;
/// <summary>
/// 订阅到期检查任务:到期进入宽限期,宽限期到期自动暂停。
/// </summary>
public sealed class SubscriptionExpiryCheckJob(
IMediator mediator,
IOptionsMonitor<SubscriptionAutomationOptions> optionsMonitor,
ILogger<SubscriptionExpiryCheckJob> logger)
{
/// <summary>
/// 执行订阅到期检查。
/// </summary>
public async Task ExecuteAsync()
{
// 1. 读取配置并执行到期处理
var options = optionsMonitor.CurrentValue;
var result = await mediator.Send(new ProcessSubscriptionExpiryCommand
{
GracePeriodDays = options.GracePeriodDays
});
// 2. 记录执行结果
logger.LogInformation(
"定时任务:订阅到期检查完成,进入宽限期 {EnteredGracePeriodCount},暂停 {SuspendedCount}",
result.EnteredGracePeriodCount,
result.SuspendedCount);
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Module.Scheduler.Options;
namespace TakeoutSaaS.Module.Scheduler.Jobs;
/// <summary>
/// 订阅续费提醒任务:到期前 7/3/1 天发送站内提醒。
/// </summary>
public sealed class SubscriptionRenewalReminderJob(
IMediator mediator,
IOptionsMonitor<SubscriptionAutomationOptions> optionsMonitor,
ILogger<SubscriptionRenewalReminderJob> logger)
{
/// <summary>
/// 执行续费提醒扫描与发送。
/// </summary>
public async Task ExecuteAsync()
{
// 1. 读取配置并执行续费提醒
var options = optionsMonitor.CurrentValue;
var result = await mediator.Send(new ProcessRenewalRemindersCommand
{
ReminderDaysBeforeExpiry = options.ReminderDaysBeforeExpiry
});
// 2. 记录执行结果
logger.LogInformation(
"定时任务:续费提醒处理完成,候选 {CandidateCount},创建 {CreatedReminderCount}",
result.CandidateCount,
result.CreatedReminderCount);
}
}

View File

@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Module.Scheduler.Options;
/// <summary>
/// 订阅自动化相关配置(续费提醒、自动续费、宽限期处理)。
/// </summary>
public sealed class SubscriptionAutomationOptions
{
/// <summary>
/// 自动续费任务执行小时UTC
/// </summary>
[Range(0, 23)]
public int AutoRenewalExecuteHourUtc { get; set; } = 1;
/// <summary>
/// 自动续费:到期前 N 天生成续费账单。
/// </summary>
[Range(0, 365)]
public int AutoRenewalDaysBeforeExpiry { get; set; } = 3;
/// <summary>
/// 续费提醒任务执行小时UTC
/// </summary>
[Range(0, 23)]
public int RenewalReminderExecuteHourUtc { get; set; } = 10;
/// <summary>
/// 续费提醒:到期前 N 天发送提醒。
/// </summary>
[MinLength(1)]
public int[] ReminderDaysBeforeExpiry { get; set; } = [7, 3, 1];
/// <summary>
/// 订阅到期检查任务执行小时UTC
/// </summary>
[Range(0, 23)]
public int SubscriptionExpiryCheckExecuteHourUtc { get; set; } = 2;
/// <summary>
/// 宽限期天数。
/// </summary>
[Range(0, 365)]
public int GracePeriodDays { get; set; } = 7;
}

View File

@@ -1,20 +1,40 @@
using Hangfire;
using Hangfire;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Module.Scheduler.Abstractions;
using TakeoutSaaS.Module.Scheduler.Jobs;
using TakeoutSaaS.Module.Scheduler.Options;
namespace TakeoutSaaS.Module.Scheduler.Services;
/// <summary>
/// 周期性任务注册器。
/// </summary>
public sealed class RecurringJobRegistrar : IRecurringJobRegistrar
public sealed class RecurringJobRegistrar(IOptionsMonitor<SubscriptionAutomationOptions> subscriptionAutomationOptions) : IRecurringJobRegistrar
{
/// <inheritdoc />
public Task RegisterAsync(CancellationToken cancellationToken = default)
{
// 1. 业务占位任务(示例)
RecurringJob.AddOrUpdate<OrderTimeoutJob>("orders.timeout-cancel", job => job.ExecuteAsync(), "*/5 * * * *");
RecurringJob.AddOrUpdate<CouponExpireJob>("coupons.expire", job => job.ExecuteAsync(), "0 */1 * * *");
RecurringJob.AddOrUpdate<LogCleanupJob>("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *");
// 2. (空行后) 订阅自动化任务(自动续费、续费提醒、到期进入宽限期)
var options = subscriptionAutomationOptions.CurrentValue;
RecurringJob.AddOrUpdate<SubscriptionAutoRenewalJob>(
"subscriptions.auto-renewal",
job => job.ExecuteAsync(),
$"0 {options.AutoRenewalExecuteHourUtc} * * *");
RecurringJob.AddOrUpdate<SubscriptionRenewalReminderJob>(
"subscriptions.renewal-reminder",
job => job.ExecuteAsync(),
$"0 {options.RenewalReminderExecuteHourUtc} * * *");
RecurringJob.AddOrUpdate<SubscriptionExpiryCheckJob>(
"subscriptions.expiry-check",
job => job.ExecuteAsync(),
$"0 {options.SubscriptionExpiryCheckExecuteHourUtc} * * *");
return Task.CompletedTask;
}
}

View File

@@ -16,5 +16,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
</ItemGroup>
</Project>