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:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user