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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 套餐查询
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user