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