feat: 新增配额包/支付相关实体与迁移
App:新增 operation_logs/quota_packages/tenant_payments/tenant_quota_package_purchases 表 Identity:修正 Avatar 字段类型(varchar(256)->text),保持现有数据不变
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费后台服务。
|
||||
/// 定期检查开启自动续费的订阅,在到期前自动生成续费账单。
|
||||
/// </summary>
|
||||
public sealed class AutoRenewalService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AutoRenewalService> _logger;
|
||||
private readonly AutoRenewalOptions _options;
|
||||
|
||||
public AutoRenewalService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AutoRenewalService> logger,
|
||||
IOptions<AutoRenewalOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("自动续费服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天执行)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("自动续费服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await ProcessAutoRenewalsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "自动续费服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("自动续费服务已停止");
|
||||
}
|
||||
|
||||
private async Task ProcessAutoRenewalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始处理自动续费");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var renewalThreshold = now.AddDays(_options.RenewalDaysBeforeExpiry);
|
||||
var billsCreated = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 查询开启自动续费且即将到期的活跃订阅
|
||||
var autoRenewSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.AutoRenew
|
||||
&& s.EffectiveTo <= renewalThreshold
|
||||
&& s.EffectiveTo > now)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
sub => sub.TenantPackageId,
|
||||
package => package.Id,
|
||||
(sub, package) => new { Subscription = sub, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in autoRenewSubscriptions)
|
||||
{
|
||||
// 检查是否已为本次到期生成过账单
|
||||
var existingBill = await dbContext.TenantBillingStatements
|
||||
.AnyAsync(b => b.TenantId == item.Subscription.TenantId
|
||||
&& b.PeriodStart >= item.Subscription.EffectiveTo
|
||||
&& b.Status != TenantBillingStatus.Cancelled,
|
||||
cancellationToken);
|
||||
|
||||
if (existingBill)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} 已存在续费账单,跳过",
|
||||
item.Subscription.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成续费账单
|
||||
var billNo = $"BILL-{DateTime.UtcNow:yyyyMMddHHmmss}-{item.Subscription.TenantId}";
|
||||
var periodStart = item.Subscription.EffectiveTo;
|
||||
|
||||
// 从当前订阅计算续费周期(月数)
|
||||
var currentDurationMonths = ((item.Subscription.EffectiveTo.Year - item.Subscription.EffectiveFrom.Year) * 12)
|
||||
+ item.Subscription.EffectiveTo.Month - item.Subscription.EffectiveFrom.Month;
|
||||
if (currentDurationMonths <= 0) currentDurationMonths = 1; // 至少1个月
|
||||
|
||||
var periodEnd = periodStart.AddMonths(currentDurationMonths);
|
||||
|
||||
// 根据续费周期计算价格(年付优惠)
|
||||
var renewalPrice = currentDurationMonths >= 12
|
||||
? (item.Package.YearlyPrice ?? item.Package.MonthlyPrice * 12 ?? 0)
|
||||
: (item.Package.MonthlyPrice ?? 0) * currentDurationMonths;
|
||||
|
||||
var bill = new TenantBillingStatement
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
StatementNo = billNo,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
AmountDue = renewalPrice,
|
||||
AmountPaid = 0,
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = periodStart.AddDays(-1), // 到期前一天为付款截止日
|
||||
LineItemsJson = $"{{\"套餐名称\":\"{item.Package.Name}\",\"续费周期\":\"{currentDurationMonths}个月\"}}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantBillingStatements.Add(bill);
|
||||
billsCreated++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"为订阅 {SubscriptionId} (租户 {TenantId}) 生成自动续费账单 {BillNo},金额 {Amount}",
|
||||
item.Subscription.Id, item.Subscription.TenantId, billNo, renewalPrice);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("自动续费处理完成,共生成 {Count} 张账单", billsCreated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "自动续费处理失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费配置选项。
|
||||
/// </summary>
|
||||
public sealed class AutoRenewalOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认凌晨1点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 在到期前N天生成续费账单,默认3天。
|
||||
/// </summary>
|
||||
public int RenewalDaysBeforeExpiry { get; set; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒后台服务。
|
||||
/// 定期检查即将到期的订阅,发送续费提醒通知。
|
||||
/// </summary>
|
||||
public sealed class RenewalReminderService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<RenewalReminderService> _logger;
|
||||
private readonly RenewalReminderOptions _options;
|
||||
|
||||
public RenewalReminderService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<RenewalReminderService> logger,
|
||||
IOptions<RenewalReminderOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("续费提醒服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天执行)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("续费提醒服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await SendRenewalRemindersAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "续费提醒服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("续费提醒服务已停止");
|
||||
}
|
||||
|
||||
private async Task SendRenewalRemindersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始发送续费提醒");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var remindersSent = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 遍历配置的提醒时间点(例如:到期前7天、3天、1天)
|
||||
foreach (var daysBeforeExpiry in _options.ReminderDaysBeforeExpiry)
|
||||
{
|
||||
var targetDate = now.AddDays(daysBeforeExpiry);
|
||||
var startOfDay = targetDate.Date;
|
||||
var endOfDay = startOfDay.AddDays(1);
|
||||
|
||||
// 查询即将到期的活跃订阅(且未开启自动续费)
|
||||
var expiringSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& !s.AutoRenew
|
||||
&& s.EffectiveTo >= startOfDay
|
||||
&& s.EffectiveTo < endOfDay)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
|
||||
)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new { combined.Subscription, combined.Tenant, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in expiringSubscriptions)
|
||||
{
|
||||
// 检查是否已发送过相同天数的提醒(避免重复发送)
|
||||
var alreadySent = await dbContext.TenantNotifications
|
||||
.AnyAsync(n => n.TenantId == item.Subscription.TenantId
|
||||
&& n.Message.Contains($"{daysBeforeExpiry}天内到期")
|
||||
&& n.SentAt >= now.AddHours(-24), // 24小时内已发送过
|
||||
cancellationToken);
|
||||
|
||||
if (alreadySent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建续费提醒通知
|
||||
var notification = new TenantNotification
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
Title = "订阅续费提醒",
|
||||
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
|
||||
Severity = daysBeforeExpiry <= 1
|
||||
? TenantNotificationSeverity.Critical
|
||||
: TenantNotificationSeverity.Warning,
|
||||
Channel = TenantNotificationChannel.InApp,
|
||||
SentAt = DateTime.UtcNow,
|
||||
ReadAt = null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantNotifications.Add(notification);
|
||||
remindersSent++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"发送续费提醒: 租户 {TenantName} ({TenantId}), 套餐 {PackageName}, 剩余 {Days} 天",
|
||||
item.Tenant.Name, item.Subscription.TenantId, item.Package.Name, daysBeforeExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("续费提醒发送完成,共发送 {Count} 条提醒", remindersSent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "发送续费提醒失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒配置选项。
|
||||
/// </summary>
|
||||
public sealed class RenewalReminderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认上午10点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒时间点(到期前N天),默认7天、3天、1天。
|
||||
/// </summary>
|
||||
public int[] ReminderDaysBeforeExpiry { get; set; } = { 7, 3, 1 };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查后台服务。
|
||||
/// 每天凌晨执行,检查即将到期和已到期的订阅,自动更新状态。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionExpiryCheckService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<SubscriptionExpiryCheckService> _logger;
|
||||
private readonly SubscriptionExpiryCheckOptions _options;
|
||||
|
||||
public SubscriptionExpiryCheckService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SubscriptionExpiryCheckService> logger,
|
||||
IOptions<SubscriptionExpiryCheckOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("订阅到期检查服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天凌晨)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("订阅到期检查服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await CheckExpiringSubscriptionsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "订阅到期检查服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("订阅到期检查服务已停止");
|
||||
}
|
||||
|
||||
private async Task CheckExpiringSubscriptionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始执行订阅到期检查");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var gracePeriodDays = _options.GracePeriodDays;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 检查活跃订阅中已到期的,转为宽限期
|
||||
var expiredActive = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in expiredActive)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.GracePeriod;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 已到期,进入宽限期",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 2. 检查宽限期订阅中超过宽限期的,转为暂停
|
||||
var gracePeriodExpired = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.GracePeriod
|
||||
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in gracePeriodExpired)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.Suspended;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 宽限期已结束,已暂停",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 3. 保存更改
|
||||
var changedCount = await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"订阅到期检查完成,共更新 {Count} 条记录 (到期转宽限期: {ExpiredCount}, 宽限期转暂停: {SuspendedCount})",
|
||||
changedCount, expiredActive.Count, gracePeriodExpired.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "订阅到期检查失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查配置选项。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionExpiryCheckOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认凌晨2点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 宽限期天数,默认7天。
|
||||
/// </summary>
|
||||
public int GracePeriodDays { get; set; } = 7;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"BackgroundServices": {
|
||||
"SubscriptionExpiryCheck": {
|
||||
"ExecuteHour": 2,
|
||||
"GracePeriodDays": 7
|
||||
},
|
||||
"RenewalReminder": {
|
||||
"ExecuteHour": 10,
|
||||
"ReminderDaysBeforeExpiry": [7, 3, 1]
|
||||
},
|
||||
"AutoRenewal": {
|
||||
"ExecuteHour": 1,
|
||||
"RenewalDaysBeforeExpiry": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user