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