feat: 新增配额包/支付相关实体与迁移

App:新增 operation_logs/quota_packages/tenant_payments/tenant_quota_package_purchases 表

Identity:修正 Avatar 字段类型(varchar(256)->text),保持现有数据不变
This commit is contained in:
2025-12-17 17:27:45 +08:00
parent 9c28790f5e
commit ab59e2e3e2
103 changed files with 14450 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
{
"BackgroundServices": {
"SubscriptionExpiryCheck": {
"ExecuteHour": 2,
"GracePeriodDays": 7
},
"RenewalReminder": {
"ExecuteHour": 10,
"ReminderDaysBeforeExpiry": [7, 3, 1]
},
"AutoRenewal": {
"ExecuteHour": 1,
"RenewalDaysBeforeExpiry": 3
}
}
}