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:
2025-12-17 21:06:01 +08:00
parent ab59e2e3e2
commit 98f49ea7ad
19 changed files with 815 additions and 2 deletions

View File

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

View File

@@ -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)
{

View File

@@ -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)
{