diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessAutoRenewalCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessAutoRenewalCommand.cs new file mode 100644 index 0000000..50849d7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessAutoRenewalCommand.cs @@ -0,0 +1,33 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.App.Subscriptions.Commands; + +/// +/// 处理自动续费:为开启自动续费且即将到期的订阅生成续费账单。 +/// +public sealed record ProcessAutoRenewalCommand : IRequest +{ + /// + /// 到期前 N 天生成续费账单。 + /// + [Range(0, 365, ErrorMessage = "续费提前天数必须在 0~365 之间")] + public int RenewalDaysBeforeExpiry { get; init; } = 3; +} + +/// +/// 自动续费处理结果。 +/// +public sealed record ProcessAutoRenewalResult +{ + /// + /// 扫描到的候选订阅数量。 + /// + public int CandidateCount { get; init; } + + /// + /// 实际创建的账单数量。 + /// + public int CreatedBillCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessRenewalRemindersCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessRenewalRemindersCommand.cs new file mode 100644 index 0000000..701c542 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessRenewalRemindersCommand.cs @@ -0,0 +1,33 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.App.Subscriptions.Commands; + +/// +/// 处理续费提醒:按到期前指定天数批量创建站内提醒通知(幂等)。 +/// +public sealed record ProcessRenewalRemindersCommand : IRequest +{ + /// + /// 提醒时间点(到期前 N 天)。 + /// + [MinLength(1, ErrorMessage = "至少需要配置一个提醒时间点")] + public IReadOnlyList ReminderDaysBeforeExpiry { get; init; } = [7, 3, 1]; +} + +/// +/// 续费提醒处理结果。 +/// +public sealed record ProcessRenewalRemindersResult +{ + /// + /// 扫描到的候选订阅数量。 + /// + public int CandidateCount { get; init; } + + /// + /// 实际创建的提醒数量。 + /// + public int CreatedReminderCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessSubscriptionExpiryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessSubscriptionExpiryCommand.cs new file mode 100644 index 0000000..9de607e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ProcessSubscriptionExpiryCommand.cs @@ -0,0 +1,33 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.App.Subscriptions.Commands; + +/// +/// 处理订阅到期:到期进入宽限期,宽限期结束自动暂停。 +/// +public sealed record ProcessSubscriptionExpiryCommand : IRequest +{ + /// + /// 宽限期天数。 + /// + [Range(0, 365, ErrorMessage = "宽限期天数必须在 0~365 之间")] + public int GracePeriodDays { get; init; } = 7; +} + +/// +/// 订阅到期处理结果。 +/// +public sealed record ProcessSubscriptionExpiryResult +{ + /// + /// 从 Active 进入宽限期的数量。 + /// + public int EnteredGracePeriodCount { get; init; } + + /// + /// 宽限期到期并暂停的数量。 + /// + public int SuspendedCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs new file mode 100644 index 0000000..a8f209b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs @@ -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; + +/// +/// 自动续费处理器:生成续费账单(幂等)。 +/// +public sealed class ProcessAutoRenewalCommandHandler( + ISubscriptionRepository subscriptionRepository, + ITenantBillingRepository billingRepository, + IIdGenerator idGenerator, + ILogger logger) + : IRequestHandler +{ + /// + public async Task 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; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs new file mode 100644 index 0000000..eba2fe6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs @@ -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; + +/// +/// 续费提醒处理器:在到期前 7/3/1 天等时间点发送站内提醒(幂等)。 +/// +public sealed class ProcessRenewalRemindersCommandHandler( + ISubscriptionRepository subscriptionRepository, + ITenantNotificationRepository notificationRepository, + IIdGenerator idGenerator, + ILogger logger) + : IRequestHandler +{ + private const string ReminderTitle = "订阅续费提醒"; + + /// + public async Task 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") + }); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessSubscriptionExpiryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessSubscriptionExpiryCommandHandler.cs new file mode 100644 index 0000000..4d25680 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessSubscriptionExpiryCommandHandler.cs @@ -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; + +/// +/// 订阅到期处理器:自动进入宽限期并在宽限期结束后暂停。 +/// +public sealed class ProcessSubscriptionExpiryCommandHandler( + ISubscriptionRepository subscriptionRepository, + ILogger logger) + : IRequestHandler +{ + /// + public async Task 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 + }; + } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs index 54ee43d..6875b76 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs @@ -56,6 +56,52 @@ public interface ISubscriptionRepository IEnumerable subscriptionIds, CancellationToken cancellationToken = default); + /// + /// 查询自动续费候选订阅(活跃 + 开启自动续费 + 即将到期)。 + /// + /// 当前时间(UTC)。 + /// 续费阈值时间(UTC),到期时间小于等于该时间视为候选。 + /// 取消标记。 + /// 候选订阅集合(含套餐信息)。 + Task> FindAutoRenewalCandidatesAsync( + DateTime now, + DateTime renewalThreshold, + CancellationToken cancellationToken = default); + + /// + /// 查询续费提醒候选订阅(活跃 + 未开启自动续费 + 到期时间落在指定日期范围)。 + /// + /// 筛选开始时间(UTC,含)。 + /// 筛选结束时间(UTC,不含)。 + /// 取消标记。 + /// 候选订阅集合(含租户与套餐信息)。 + Task> FindRenewalReminderCandidatesAsync( + DateTime startOfDay, + DateTime endOfDay, + CancellationToken cancellationToken = default); + + /// + /// 查询已到期仍处于 Active 的订阅(用于进入宽限期)。 + /// + /// 当前时间(UTC)。 + /// 取消标记。 + /// 到期订阅集合。 + Task> FindExpiredActiveSubscriptionsAsync( + DateTime now, + CancellationToken cancellationToken = default); + + /// + /// 查询宽限期已结束的订阅(用于自动暂停)。 + /// + /// 当前时间(UTC)。 + /// 宽限期天数。 + /// 取消标记。 + /// 宽限期到期订阅集合。 + Task> FindGracePeriodExpiredSubscriptionsAsync( + DateTime now, + int gracePeriodDays, + CancellationToken cancellationToken = default); + #endregion #region 套餐查询 @@ -271,6 +317,43 @@ public record SubscriptionWithTenant public required Tenant Tenant { get; init; } } +/// +/// 自动续费候选订阅信息。 +/// +public sealed record AutoRenewalCandidate +{ + /// + /// 订阅实体。 + /// + public required TenantSubscription Subscription { get; init; } + + /// + /// 当前套餐实体。 + /// + public required TenantPackage Package { get; init; } +} + +/// +/// 续费提醒候选订阅信息。 +/// +public sealed record RenewalReminderCandidate +{ + /// + /// 订阅实体。 + /// + public required TenantSubscription Subscription { get; init; } + + /// + /// 租户实体。 + /// + public required Tenant Tenant { get; init; } + + /// + /// 当前套餐实体。 + /// + public required TenantPackage Package { get; init; } +} + /// /// 订阅历史(含套餐名称)。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs index 81e222d..8baae69 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs @@ -42,6 +42,18 @@ public interface ITenantBillingRepository /// 账单实体或 null。 Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default); + /// + /// 判断是否已存在指定周期开始时间的未取消账单(用于自动续费幂等)。 + /// + /// 租户 ID。 + /// 账单周期开始时间(UTC)。 + /// 取消标记。 + /// 存在返回 true,否则 false。 + Task ExistsNotCancelledByPeriodStartAsync( + long tenantId, + DateTime periodStart, + CancellationToken cancellationToken = default); + /// /// 新增账单。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs index c81f613..3f14c8b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs @@ -35,6 +35,22 @@ public interface ITenantNotificationRepository /// 通知实体或 null。 Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default); + /// + /// 判断是否已发送过指定元数据的通知(用于幂等控制)。 + /// + /// 租户 ID。 + /// 通知标题。 + /// 元数据 JSON(需与写入值完全一致)。 + /// 只在该时间之后发送的记录范围内判断(UTC)。 + /// 取消标记。 + /// 存在返回 true,否则 false。 + Task ExistsByMetadataAsync( + long tenantId, + string title, + string metadataJson, + DateTime sentAfter, + CancellationToken cancellationToken = default); + /// /// 新增通知。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs index 281c941..42c9a34 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs @@ -165,6 +165,88 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS .ToListAsync(cancellationToken); } + /// + public async Task> 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); + } + + /// + public async Task> 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); + } + + /// + public async Task> FindExpiredActiveSubscriptionsAsync( + DateTime now, + CancellationToken cancellationToken = default) + { + // 1. 查询已到期仍为 Active 的订阅 + return await dbContext.TenantSubscriptions + .Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now) + .ToListAsync(cancellationToken); + } + + /// + public async Task> 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 套餐查询 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs index c71e7a9..3d9f51a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs @@ -120,6 +120,20 @@ public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITe .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken); } + /// + public Task 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); + } + /// public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs index 27417cd..8738d6e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs @@ -56,6 +56,23 @@ public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken); } + /// + public Task 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); + } + /// public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default) { diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs index db86c46..34bd320 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs @@ -27,6 +27,11 @@ public static class SchedulerServiceCollectionExtensions .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .Bind(configuration.GetSection("Scheduler:SubscriptionAutomation")) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddHangfire((serviceProvider, config) => { var options = serviceProvider.GetRequiredService>().CurrentValue; @@ -51,6 +56,9 @@ public static class SchedulerServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionAutoRenewalJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionAutoRenewalJob.cs new file mode 100644 index 0000000..648f889 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionAutoRenewalJob.cs @@ -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; + +/// +/// 订阅自动续费任务:为即将到期且开启自动续费的订阅生成续费账单。 +/// +public sealed class SubscriptionAutoRenewalJob( + IMediator mediator, + IOptionsMonitor optionsMonitor, + ILogger logger) +{ + /// + /// 执行自动续费账单生成。 + /// + 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); + } +} + diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionExpiryCheckJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionExpiryCheckJob.cs new file mode 100644 index 0000000..a6de16a --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionExpiryCheckJob.cs @@ -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; + +/// +/// 订阅到期检查任务:到期进入宽限期,宽限期到期自动暂停。 +/// +public sealed class SubscriptionExpiryCheckJob( + IMediator mediator, + IOptionsMonitor optionsMonitor, + ILogger logger) +{ + /// + /// 执行订阅到期检查。 + /// + 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); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionRenewalReminderJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionRenewalReminderJob.cs new file mode 100644 index 0000000..80b7462 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/SubscriptionRenewalReminderJob.cs @@ -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; + +/// +/// 订阅续费提醒任务:到期前 7/3/1 天发送站内提醒。 +/// +public sealed class SubscriptionRenewalReminderJob( + IMediator mediator, + IOptionsMonitor optionsMonitor, + ILogger logger) +{ + /// + /// 执行续费提醒扫描与发送。 + /// + 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); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SubscriptionAutomationOptions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SubscriptionAutomationOptions.cs new file mode 100644 index 0000000..91e1601 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SubscriptionAutomationOptions.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Scheduler.Options; + +/// +/// 订阅自动化相关配置(续费提醒、自动续费、宽限期处理)。 +/// +public sealed class SubscriptionAutomationOptions +{ + /// + /// 自动续费任务执行小时(UTC)。 + /// + [Range(0, 23)] + public int AutoRenewalExecuteHourUtc { get; set; } = 1; + + /// + /// 自动续费:到期前 N 天生成续费账单。 + /// + [Range(0, 365)] + public int AutoRenewalDaysBeforeExpiry { get; set; } = 3; + + /// + /// 续费提醒任务执行小时(UTC)。 + /// + [Range(0, 23)] + public int RenewalReminderExecuteHourUtc { get; set; } = 10; + + /// + /// 续费提醒:到期前 N 天发送提醒。 + /// + [MinLength(1)] + public int[] ReminderDaysBeforeExpiry { get; set; } = [7, 3, 1]; + + /// + /// 订阅到期检查任务执行小时(UTC)。 + /// + [Range(0, 23)] + public int SubscriptionExpiryCheckExecuteHourUtc { get; set; } = 2; + + /// + /// 宽限期天数。 + /// + [Range(0, 365)] + public int GracePeriodDays { get; set; } = 7; +} + diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs index 6e3d559..7dd98b2 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs @@ -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; /// /// 周期性任务注册器。 /// -public sealed class RecurringJobRegistrar : IRecurringJobRegistrar +public sealed class RecurringJobRegistrar(IOptionsMonitor subscriptionAutomationOptions) : IRecurringJobRegistrar { /// public Task RegisterAsync(CancellationToken cancellationToken = default) { + // 1. 业务占位任务(示例) RecurringJob.AddOrUpdate("orders.timeout-cancel", job => job.ExecuteAsync(), "*/5 * * * *"); RecurringJob.AddOrUpdate("coupons.expire", job => job.ExecuteAsync(), "0 */1 * * *"); RecurringJob.AddOrUpdate("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *"); + + // 2. (空行后) 订阅自动化任务(自动续费、续费提醒、到期进入宽限期) + var options = subscriptionAutomationOptions.CurrentValue; + RecurringJob.AddOrUpdate( + "subscriptions.auto-renewal", + job => job.ExecuteAsync(), + $"0 {options.AutoRenewalExecuteHourUtc} * * *"); + RecurringJob.AddOrUpdate( + "subscriptions.renewal-reminder", + job => job.ExecuteAsync(), + $"0 {options.RenewalReminderExecuteHourUtc} * * *"); + RecurringJob.AddOrUpdate( + "subscriptions.expiry-check", + job => job.ExecuteAsync(), + $"0 {options.SubscriptionExpiryCheckExecuteHourUtc} * * *"); + return Task.CompletedTask; } } + diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj index 5024078..b8a7787 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj @@ -16,5 +16,6 @@ +