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

@@ -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>