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
};
}
}