refactor: 移除租户侧能力

This commit is contained in:
2026-01-30 02:32:01 +00:00
parent 6143943bf0
commit 83a4eb0831
249 changed files with 5 additions and 18156 deletions

View File

@@ -10,12 +10,10 @@ using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Infrastructure.App.Options;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
using TakeoutSaaS.Infrastructure.App.Repositories;
using TakeoutSaaS.Infrastructure.App.Services;
using TakeoutSaaS.Infrastructure.Common.Extensions;
@@ -48,22 +46,10 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
services.AddScoped<ITenantRepository, EfTenantRepository>();
services.AddScoped<ITenantBillingRepository, TenantBillingRepository>();
services.AddScoped<ITenantPaymentRepository, TenantPaymentRepository>();
services.AddScoped<ITenantAnnouncementRepository, EfTenantAnnouncementRepository>();
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
services.AddScoped<IOperationLogRepository, EfOperationLogRepository>();
// 1. 账单领域/导出服务
services.AddScoped<IBillingDomainService, BillingDomainService>();
services.AddScoped<IBillingExportService, BillingExportService>();
// 1. 商户导出服务
services.AddScoped<IMerchantExportService, MerchantExportService>();
// 2. (空行后) 门店配置服务

View File

@@ -1,378 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户账单仓储实现EF Core
/// </summary>
public sealed class TenantBillingRepository(TakeoutAdminDbContext context) : ITenantBillingRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> SearchAsync(
long tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询:忽略全局过滤器,显式过滤租户与软删除
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
// 2. 按状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 3. 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 4. 排序返回
return await query
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsNotCancelledByPeriodStartAsync(
long tenantId,
DateTime periodStart,
CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.AnyAsync(
x => x.DeletedAt == null
&& x.TenantId == tenantId
&& x.PeriodStart == periodStart
&& x.Status != TenantBillingStatus.Cancelled,
cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
// 1. 以当前 UTC 时间作为逾期判断基准
var now = DateTime.UtcNow;
// 2. 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.DueDate < now
&& x.Status == TenantBillingStatus.Pending)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default)
{
// 1. 计算到期窗口
var now = DateTime.UtcNow;
var dueTo = now.AddDays(daysAhead);
// 2. 仅查询待支付账单
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.Status == TenantBillingStatus.Pending
&& x.DueDate >= now
&& x.DueDate <= dueTo)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByIdsAsync(IReadOnlyCollection<long> billingIds, CancellationToken cancellationToken = default)
{
if (billingIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
// 1. 忽略全局过滤器以支持管理员端跨租户导出/批量操作
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && billingIds.Contains(x.Id))
.OrderByDescending(x => x.PeriodStart)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
context.TenantBillingStatements.Update(bill);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
long? tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询(管理员端跨租户查询,忽略过滤器)
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null);
// 2. 按租户过滤(可选)
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. 按状态过滤(可选)
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 4. 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 5. 按金额范围过滤(应付金额,包含边界)
if (minAmount.HasValue)
{
query = query.Where(x => x.AmountDue >= minAmount.Value);
}
if (maxAmount.HasValue)
{
query = query.Where(x => x.AmountDue <= maxAmount.Value);
}
// 6. 关键字过滤(账单号或租户名)
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query =
from b in query
join t in context.Tenants
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null)
on b.TenantId equals t.Id
where EF.Functions.ILike(b.StatementNo, $"%{normalized}%")
|| EF.Functions.ILike(t.Name, $"%{normalized}%")
select b;
}
// 7. 统计总数
var total = await query.CountAsync(cancellationToken);
// 8. 分页查询
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public async Task<TenantBillingStatistics> GetStatisticsAsync(
long? tenantId,
DateTime startDate,
DateTime endDate,
string groupBy,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询(忽略过滤器,显式过滤软删除/租户/时间范围)
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& (!tenantId.HasValue || x.TenantId == tenantId.Value)
&& x.PeriodStart >= startDate
&& x.PeriodEnd <= endDate);
// 2. 聚合统计(金额统一使用:应付 - 折扣 + 税费)
var now = DateTime.UtcNow;
var totalAmount = await query.SumAsync(x => x.AmountDue - x.DiscountAmount + x.TaxAmount, cancellationToken);
var paidAmount = await query.Where(x => x.Status == TenantBillingStatus.Paid).SumAsync(x => x.AmountPaid, cancellationToken);
var unpaidAmount = await query.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
var overdueAmount = await query
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
// 3. 数量统计
var totalCount = await query.CountAsync(cancellationToken);
var paidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Paid, cancellationToken);
var unpaidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue, cancellationToken);
var overdueCount = await query.CountAsync(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now, cancellationToken);
// 4. 趋势统计
var normalizedGroupBy = NormalizeGroupBy(groupBy);
var trendRaw = await query
.Select(x => new
{
x.PeriodStart,
x.AmountDue,
x.DiscountAmount,
x.TaxAmount,
x.AmountPaid
})
.ToListAsync(cancellationToken);
// 4.1 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展)
var trend = trendRaw
.GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy))
.Select(g => new TenantBillingTrendDataPoint
{
Period = g.Key,
Count = g.Count(),
TotalAmount = g.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount),
PaidAmount = g.Sum(x => x.AmountPaid)
})
.OrderBy(x => x.Period)
.ToList();
return new TenantBillingStatistics
{
TotalAmount = totalAmount,
PaidAmount = paidAmount,
UnpaidAmount = unpaidAmount,
OverdueAmount = overdueAmount,
TotalCount = totalCount,
PaidCount = paidCount,
UnpaidCount = unpaidCount,
OverdueCount = overdueCount,
TrendData = trend
};
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
}
private static string NormalizeGroupBy(string groupBy)
{
return groupBy.Trim() switch
{
"Week" => "Week",
"Month" => "Month",
_ => "Day"
};
}
private static DateTime GetTrendBucket(DateTime periodStart, string groupBy)
{
var date = periodStart.Date;
return groupBy switch
{
"Month" => new DateTime(date.Year, date.Month, 1, 0, 0, 0, DateTimeKind.Utc),
"Week" => GetWeekStart(date),
_ => new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc)
};
}
private static DateTime GetWeekStart(DateTime date)
{
// 1. 将周一作为一周起始(与 PostgreSQL date_trunc('week', ...) 对齐)
var dayOfWeek = (int)date.DayOfWeek; // Sunday=0, Monday=1, ...
var daysSinceMonday = (dayOfWeek + 6) % 7;
// 2. 回退到周一 00:00:00UTC
var monday = date.AddDays(-daysSinceMonday);
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
}
}

View File

@@ -1,76 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户支付记录仓储实现EF Core
/// </summary>
public sealed class TenantPaymentRepository(TakeoutAdminDbContext context) : ITenantPaymentRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
return await context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<decimal> GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
// 1. 仅统计支付成功的记录
return await context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.BillingStatementId == billingStatementId
&& x.Status == TenantPaymentStatus.Success)
.SumAsync(x => x.Amount, cancellationToken);
}
/// <inheritdoc />
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
{
return context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantPayment?> GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default)
{
var normalized = transactionNo.Trim();
return context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
context.TenantPayments.Update(payment);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,169 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 配额包仓储实现。
/// </summary>
public sealed class EfQuotaPackageRepository(TakeoutAdminDbContext context) : IQuotaPackageRepository
{
#region
/// <inheritdoc />
public Task<QuotaPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
{
return context.QuotaPackages
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<QuotaPackage> Items, int Total)> SearchPagedAsync(
TenantQuotaType? quotaType,
bool? isActive,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.QuotaPackages.AsNoTracking()
.Where(x => x.DeletedAt == null);
if (quotaType.HasValue)
{
query = query.Where(x => x.QuotaType == quotaType.Value);
}
if (isActive.HasValue)
{
query = query.Where(x => x.IsActive == isActive.Value);
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public Task AddAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
{
return context.QuotaPackages.AddAsync(quotaPackage, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
{
context.QuotaPackages.Update(quotaPackage);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<bool> SoftDeleteAsync(long id, CancellationToken cancellationToken = default)
{
var quotaPackage = await context.QuotaPackages
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
if (quotaPackage == null)
{
return false;
}
quotaPackage.DeletedAt = DateTime.UtcNow;
return true;
}
#endregion
#region
/// <inheritdoc />
public async Task<(IReadOnlyList<(TenantQuotaPackagePurchase Purchase, QuotaPackage Package)> Items, int Total)> GetPurchasesPagedAsync(
long tenantId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.TenantQuotaPackagePurchases
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.PurchasedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Join(context.QuotaPackages.AsNoTracking(),
purchase => purchase.QuotaPackageId,
package => package.Id,
(purchase, package) => new { Purchase = purchase, Package = package })
.ToListAsync(cancellationToken);
return (items.Select(x => (x.Purchase, x.Package)).ToList(), total);
}
/// <inheritdoc />
public Task AddPurchaseAsync(TenantQuotaPackagePurchase purchase, CancellationToken cancellationToken = default)
{
return context.TenantQuotaPackagePurchases.AddAsync(purchase, cancellationToken).AsTask();
}
#endregion
#region 使
/// <inheritdoc />
public async Task<IReadOnlyList<TenantQuotaUsage>> GetUsageByTenantAsync(
long tenantId,
TenantQuotaType? quotaType,
CancellationToken cancellationToken = default)
{
var query = context.TenantQuotaUsages
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (quotaType.HasValue)
{
query = query.Where(x => x.QuotaType == quotaType.Value);
}
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantQuotaUsage?> FindUsageAsync(
long tenantId,
TenantQuotaType quotaType,
CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
}
/// <inheritdoc />
public Task UpdateUsageAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
{
context.TenantQuotaUsages.Update(usage);
return Task.CompletedTask;
}
#endregion
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,116 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 统计数据仓储实现。
/// </summary>
public sealed class EfStatisticsRepository(TakeoutAdminDbContext dbContext) : IStatisticsRepository
{
#region
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> GetAllSubscriptionsAsync(CancellationToken cancellationToken = default)
{
return await dbContext.TenantSubscriptions
.AsNoTracking()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExpiringSubscriptionInfo>> GetExpiringSubscriptionsAsync(
int daysAhead,
bool onlyWithoutAutoRenew,
CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var targetDate = now.AddDays(daysAhead);
// 构建基础查询
var query = dbContext.TenantSubscriptions
.AsNoTracking()
.Where(s => s.Status == SubscriptionStatus.Active
&& s.EffectiveTo >= now
&& s.EffectiveTo <= targetDate);
// 如果只查询未开启自动续费的
if (onlyWithoutAutoRenew)
{
query = query.Where(s => !s.AutoRenew);
}
// 连接租户和套餐信息
var result = await query
.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 ExpiringSubscriptionInfo
{
Subscription = combined.Subscription,
TenantName = combined.Tenant.Name,
PackageName = package.Name
}
)
.OrderBy(x => x.Subscription.EffectiveTo)
.ToListAsync(cancellationToken);
return result;
}
#endregion
#region
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetPaidBillsAsync(CancellationToken cancellationToken = default)
{
return await dbContext.TenantBillingStatements
.AsNoTracking()
.Where(b => b.Status == TenantBillingStatus.Paid)
.ToListAsync(cancellationToken);
}
#endregion
#region 使
/// <inheritdoc />
public async Task<IReadOnlyList<QuotaUsageRankInfo>> GetQuotaUsageRankingAsync(
TenantQuotaType quotaType,
int topN,
CancellationToken cancellationToken = default)
{
return await dbContext.TenantQuotaUsages
.AsNoTracking()
.Where(q => q.QuotaType == quotaType && q.LimitValue > 0)
.Join(
dbContext.Tenants,
quota => quota.TenantId,
tenant => tenant.Id,
(quota, tenant) => new QuotaUsageRankInfo
{
TenantId = quota.TenantId,
TenantName = tenant.Name,
UsedValue = quota.UsedValue,
LimitValue = quota.LimitValue,
UsagePercentage = quota.LimitValue > 0 ? (quota.UsedValue / quota.LimitValue * 100) : 0
}
)
.OrderByDescending(x => x.UsagePercentage)
.Take(topN)
.ToListAsync(cancellationToken);
}
#endregion
}

View File

@@ -1,412 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 订阅管理仓储实现。
/// </summary>
public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, TakeoutLogsDbContext logsContext) : ISubscriptionRepository
{
#region
/// <inheritdoc />
public async Task<TenantSubscription?> FindByIdAsync(
long subscriptionId,
CancellationToken cancellationToken = default,
bool includeDeleted = false)
{
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
return await query
.FirstOrDefaultAsync(s => s.Id == subscriptionId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
IEnumerable<long> subscriptionIds,
CancellationToken cancellationToken = default,
bool includeDeleted = false)
{
var ids = subscriptionIds.ToList();
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
return await query
.Where(s => ids.Contains(s.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<SubscriptionWithRelations> Items, int Total)> SearchPagedAsync(
SubscriptionSearchFilter filter,
CancellationToken cancellationToken = default,
bool includeDeleted = false)
{
// 1. 构建基础查询
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
var query = subscriptionQuery
.AsNoTracking()
.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 }
)
.GroupJoin(
dbContext.TenantPackages,
combined => combined.Subscription.ScheduledPackageId,
scheduledPackage => scheduledPackage.Id,
(combined, scheduledPackages) => new { combined.Subscription, combined.Tenant, combined.Package, ScheduledPackage = scheduledPackages.FirstOrDefault() }
);
// 2. 应用过滤条件
if (filter.Status.HasValue)
{
query = query.Where(x => x.Subscription.Status == filter.Status.Value);
}
if (filter.TenantPackageId.HasValue)
{
query = query.Where(x => x.Subscription.TenantPackageId == filter.TenantPackageId.Value);
}
if (filter.TenantId.HasValue)
{
query = query.Where(x => x.Subscription.TenantId == filter.TenantId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.TenantKeyword))
{
var keyword = filter.TenantKeyword.Trim().ToLower();
query = query.Where(x => x.Tenant.Name.ToLower().Contains(keyword) || x.Tenant.Code.ToLower().Contains(keyword));
}
if (filter.ExpiringWithinDays.HasValue)
{
var expiryDate = DateTime.UtcNow.AddDays(filter.ExpiringWithinDays.Value);
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate && x.Subscription.EffectiveTo >= DateTime.UtcNow);
}
if (filter.AutoRenew.HasValue)
{
query = query.Where(x => x.Subscription.AutoRenew == filter.AutoRenew.Value);
}
// 3. 获取总数
var total = await query.CountAsync(cancellationToken);
// 4. 排序和分页
var items = await query
.OrderByDescending(x => x.Subscription.CreatedAt)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.Select(x => new SubscriptionWithRelations
{
Subscription = x.Subscription,
TenantName = x.Tenant.Name,
TenantCode = x.Tenant.Code,
PackageName = x.Package.Name,
ScheduledPackageName = x.ScheduledPackage != null ? x.ScheduledPackage.Name : null
})
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public async Task<SubscriptionDetailInfo?> GetDetailAsync(
long subscriptionId,
CancellationToken cancellationToken = default,
bool includeDeleted = false)
{
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
var result = await subscriptionQuery
.AsNoTracking()
.Where(s => s.Id == subscriptionId)
.Select(s => new
{
Subscription = s,
Tenant = dbContext.Tenants.FirstOrDefault(t => t.Id == s.TenantId),
Package = dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.TenantPackageId),
ScheduledPackage = s.ScheduledPackageId.HasValue
? dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.ScheduledPackageId)
: null
})
.FirstOrDefaultAsync(cancellationToken);
if (result == null)
{
return null;
}
return new SubscriptionDetailInfo
{
Subscription = result.Subscription,
TenantName = result.Tenant?.Name ?? "",
TenantCode = result.Tenant?.Code ?? "",
Package = result.Package,
ScheduledPackage = result.ScheduledPackage
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
IEnumerable<long> subscriptionIds,
CancellationToken cancellationToken = default,
bool includeDeleted = false)
{
var ids = subscriptionIds.ToList();
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
return await query
.Where(s => ids.Contains(s.Id))
.Join(
dbContext.Tenants,
sub => sub.TenantId,
tenant => tenant.Id,
(sub, tenant) => new SubscriptionWithTenant
{
Subscription = sub,
Tenant = tenant
}
)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AutoRenewalCandidate>> FindAutoRenewalCandidatesAsync(
DateTime now,
DateTime renewalThreshold,
CancellationToken cancellationToken = default,
bool includeDeleted = false)
{
// 1. 查询开启自动续费且即将到期的活跃订阅
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
var query = subscriptionQuery
.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,
bool includeDeleted = false)
{
// 1. 查询到期落在指定区间的订阅(且未开启自动续费)
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
var query = subscriptionQuery
.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,
bool includeDeleted = false)
{
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
// 1. 查询已到期仍为 Active 的订阅
return await query
.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,
bool includeDeleted = false)
{
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
// 1. 查询宽限期已结束的订阅
return await query
.Where(s => s.Status == SubscriptionStatus.GracePeriod
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
.ToListAsync(cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public async Task<TenantPackage?> FindPackageByIdAsync(long packageId, CancellationToken cancellationToken = default)
{
return await dbContext.TenantPackages
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == packageId, cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public Task UpdateAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
{
dbContext.TenantSubscriptions.Update(subscription);
return Task.CompletedTask;
}
#endregion
#region
/// <inheritdoc />
public Task AddHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
{
dbContext.TenantSubscriptionHistories.Add(history);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SubscriptionHistoryWithPackageNames>> GetHistoryAsync(
long subscriptionId,
CancellationToken cancellationToken = default)
{
return await dbContext.TenantSubscriptionHistories
.AsNoTracking()
.Where(h => h.TenantSubscriptionId == subscriptionId)
.OrderByDescending(h => h.CreatedAt)
.Select(h => new SubscriptionHistoryWithPackageNames
{
History = h,
FromPackageName = dbContext.TenantPackages
.Where(p => p.Id == h.FromPackageId)
.Select(p => p.Name)
.FirstOrDefault() ?? "",
ToPackageName = dbContext.TenantPackages
.Where(p => p.Id == h.ToPackageId)
.Select(p => p.Name)
.FirstOrDefault() ?? ""
})
.ToListAsync(cancellationToken);
}
#endregion
#region 使
/// <inheritdoc />
public async Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(
long tenantId,
CancellationToken cancellationToken = default,
bool includeDeleted = false)
{
var query = includeDeleted
? dbContext.TenantQuotaUsages.IgnoreQueryFilters()
: dbContext.TenantQuotaUsages;
return await query
.AsNoTracking()
.Where(q => q.TenantId == tenantId)
.ToListAsync(cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public Task AddNotificationAsync(TenantNotification notification, CancellationToken cancellationToken = default)
{
dbContext.TenantNotifications.Add(notification);
return Task.CompletedTask;
}
#endregion
#region
/// <inheritdoc />
public Task AddOperationLogAsync(OperationLog log, CancellationToken cancellationToken = default)
{
logsContext.OperationLogs.Add(log);
return Task.CompletedTask;
}
#endregion
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存业务库变更
await dbContext.SaveChangesAsync(cancellationToken);
// 2. 保存日志库变更
await logsContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,124 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 租户公告仓储。
/// </summary>
public sealed class EfTenantAnnouncementRepository(TakeoutAdminDbContext context) : ITenantAnnouncementRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantAnnouncement>> SearchAsync(
long tenantId,
string? keyword,
AnnouncementStatus? status,
TenantAnnouncementType? type,
bool? isActive,
DateTime? effectiveFrom,
DateTime? effectiveTo,
DateTime? effectiveAt,
bool orderByPriority = false,
int? limit = null,
CancellationToken cancellationToken = default)
{
var tenantIds = new[] { tenantId, 0L };
var query = context.TenantAnnouncements.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => tenantIds.Contains(x.TenantId));
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x =>
EF.Functions.ILike(x.Title, $"%{normalized}%")
|| EF.Functions.ILike(x.Content, $"%{normalized}%"));
}
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (type.HasValue)
{
query = query.Where(x => x.AnnouncementType == type.Value);
}
if (isActive.HasValue)
{
query = isActive.Value
? query.Where(x => x.Status == AnnouncementStatus.Published)
: query.Where(x => x.Status != AnnouncementStatus.Published);
}
if (effectiveFrom.HasValue)
{
query = query.Where(x => x.EffectiveFrom >= effectiveFrom.Value);
}
if (effectiveTo.HasValue)
{
query = query.Where(x => x.EffectiveTo == null || x.EffectiveTo <= effectiveTo.Value);
}
if (effectiveAt.HasValue)
{
var at = effectiveAt.Value;
query = query.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at));
}
// 应用排序(如果启用)
if (orderByPriority)
{
query = query.OrderByDescending(x => x.Priority).ThenByDescending(x => x.EffectiveFrom);
}
// 应用限制(如果指定)
if (limit.HasValue && limit.Value > 0)
{
query = query.Take(limit.Value);
}
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantAnnouncement?> FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
{
return context.TenantAnnouncements.AsNoTracking()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default)
{
return context.TenantAnnouncements.AddAsync(announcement, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default)
{
context.TenantAnnouncements.Update(announcement);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
{
var entity = await context.TenantAnnouncements.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken);
if (entity != null)
{
context.TenantAnnouncements.Remove(entity);
}
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,94 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 租户通知仓储。
/// </summary>
public sealed class EfTenantNotificationRepository(TakeoutAdminDbContext context) : ITenantNotificationRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<TenantNotification>> SearchAsync(
long tenantId,
TenantNotificationSeverity? severity,
bool? unreadOnly,
DateTime? from,
DateTime? to,
CancellationToken cancellationToken = default)
{
var query = context.TenantNotifications.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (severity.HasValue)
{
query = query.Where(x => x.Severity == severity.Value);
}
if (unreadOnly == true)
{
query = query.Where(x => x.ReadAt == null);
}
if (from.HasValue)
{
query = query.Where(x => x.SentAt >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.SentAt <= to.Value);
}
return query
.OrderByDescending(x => x.SentAt)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantNotification>)t.Result, cancellationToken);
}
/// <inheritdoc />
public Task<TenantNotification?> FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default)
{
return context.TenantNotifications
.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)
{
return context.TenantNotifications.AddAsync(notification, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default)
{
context.TenantNotifications.Update(notification);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,89 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户套餐仓储实现。
/// </summary>
public sealed class EfTenantPackageRepository(TakeoutAdminDbContext context) : ITenantPackageRepository
{
/// <inheritdoc />
public Task<TenantPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
{
return context.TenantPackages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackage>> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = context.TenantPackages.AsNoTracking();
// 2. 关键字过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%"));
}
// 3. 状态过滤
if (isActive.HasValue)
{
query = query.Where(x => x.IsActive == isActive.Value);
}
// 4. 排序返回
return await query
.OrderBy(x => x.SortOrder)
.ThenByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackage>> SearchPublicPurchasableAsync(CancellationToken cancellationToken = default)
{
// 1. 公共可选购套餐仅返回:已发布 + 对外可见 + 允许新购 + 启用
return await context.TenantPackages.AsNoTracking()
.Where(x =>
x.IsActive
&& x.PublishStatus == TenantPackagePublishStatus.Published
&& x.IsPublicVisible
&& x.IsAllowNewTenantPurchase)
.OrderBy(x => x.SortOrder)
.ThenByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
return context.TenantPackages.AddAsync(package, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
context.TenantPackages.Update(package);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
{
var entity = await context.TenantPackages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity != null)
{
context.TenantPackages.Remove(entity);
}
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,24 +0,0 @@
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户配额使用历史仓储实现。
/// </summary>
public sealed class EfTenantQuotaUsageHistoryRepository(TakeoutAdminDbContext context) : ITenantQuotaUsageHistoryRepository
{
/// <inheritdoc />
public Task AddAsync(TenantQuotaUsageHistory history, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsageHistories.AddAsync(history, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,50 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户配额使用仓储实现。
/// </summary>
public sealed class EfTenantQuotaUsageRepository(TakeoutAdminDbContext context) : ITenantQuotaUsageRepository
{
/// <inheritdoc />
public Task<TenantQuotaUsage?> FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<TenantQuotaUsage>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.QuotaType)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantQuotaUsage>)t.Result, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages.AddAsync(usage, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
{
context.TenantQuotaUsages.Update(usage);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,202 +0,0 @@
using System.Globalization;
using System.Text.Json;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 账单领域服务实现。
/// </summary>
public sealed class BillingDomainService(
ITenantBillingRepository billingRepository,
ITenantPackageRepository tenantPackageRepository,
IIdGenerator idGenerator) : IBillingDomainService
{
/// <inheritdoc />
public async Task<TenantBillingStatement> GenerateSubscriptionBillingAsync(
TenantSubscription subscription,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subscription);
// 1. 校验幂等:同一周期开始时间只能存在一张未取消账单
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
subscription.TenantId,
subscription.EffectiveFrom,
cancellationToken);
if (exists)
{
throw new InvalidOperationException("该订阅周期的账单已存在。");
}
// 2. 查询套餐价格信息
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
if (package is null)
{
throw new InvalidOperationException("订阅未关联有效套餐,无法生成账单。");
}
// 3. 选择价格(简化规则:优先按年/按月)
var days = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
var amountDue = days >= 300 ? package.YearlyPrice : package.MonthlyPrice;
if (!amountDue.HasValue)
{
throw new InvalidOperationException("套餐价格未配置,无法生成账单。");
}
// 4. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "Subscription",
description: $"套餐 {package.Name} 订阅费用",
quantity: 1,
unitPrice: amountDue.Value)
};
// 5. 构建账单实体
var now = DateTime.UtcNow;
return new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
StatementNo = GenerateStatementNo(),
BillingType = BillingType.Subscription,
SubscriptionId = subscription.Id,
PeriodStart = subscription.EffectiveFrom,
PeriodEnd = subscription.EffectiveTo,
AmountDue = amountDue.Value,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = subscription.Notes
};
}
/// <inheritdoc />
public Task<TenantBillingStatement> GenerateQuotaPurchaseBillingAsync(
long tenantId,
QuotaPackage quotaPackage,
int quantity,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(quotaPackage);
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(quantity), "购买数量必须大于 0。");
}
// 1. 计算金额
var amountDue = quotaPackage.Price * quantity;
// 2. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "QuotaPurchase",
description: $"配额包 {quotaPackage.Name} × {quantity}",
quantity: quantity,
unitPrice: quotaPackage.Price)
};
// 3. 构建账单实体
var now = DateTime.UtcNow;
var billing = new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = tenantId,
StatementNo = GenerateStatementNo(),
BillingType = BillingType.QuotaPurchase,
SubscriptionId = null,
PeriodStart = now,
PeriodEnd = now,
AmountDue = amountDue,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = quotaPackage.Description
};
return Task.FromResult(billing);
}
/// <inheritdoc />
public string GenerateStatementNo()
{
// 1. 账单号格式BILL-{yyyyMMdd}-{序号}
var date = DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
// 2. 使用雪花 ID 作为全局递增序号,确保分布式唯一
var sequence = idGenerator.NextId();
return $"BILL-{date}-{sequence}";
}
/// <inheritdoc />
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
// 1. 查询当前已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
if (overdueBillings.Count == 0)
{
return 0;
}
// 2. 批量标记逾期(防御性:再次判断 Pending
var processedAt = DateTime.UtcNow;
var updated = 0;
foreach (var billing in overdueBillings)
{
if (billing.Status != TenantBillingStatus.Pending)
{
continue;
}
billing.MarkAsOverdue();
billing.OverdueNotifiedAt ??= processedAt;
billing.UpdatedAt = processedAt;
await billingRepository.UpdateAsync(billing, cancellationToken);
updated++;
}
// 3. 持久化
if (updated > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
return updated;
}
/// <inheritdoc />
public decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount)
{
return baseAmount - discountAmount + taxAmount;
}
/// <inheritdoc />
public bool CanProcessPayment(TenantBillingStatement billing)
{
ArgumentNullException.ThrowIfNull(billing);
return billing.Status switch
{
TenantBillingStatus.Pending => true,
TenantBillingStatus.Overdue => true,
_ => false
};
}
}

View File

@@ -1,203 +0,0 @@
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Globalization;
using System.Text;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Services;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 账单导出服务实现Excel/PDF/CSV
/// </summary>
public sealed class BillingExportService : IBillingExportService
{
/// <summary>
/// 初始化导出服务并配置 QuestPDF 许可证。
/// </summary>
public BillingExportService()
{
QuestPDF.Settings.License = LicenseType.Community;
}
/// <inheritdoc />
public Task<byte[]> ExportToExcelAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 创建工作簿与工作表
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Billings");
// 2. 写入表头
var headers = new[]
{
"Id", "TenantId", "StatementNo", "BillingType", "Status",
"PeriodStart", "PeriodEnd", "AmountDue", "DiscountAmount", "TaxAmount", "TotalAmount",
"AmountPaid", "Currency", "DueDate", "Notes", "LineItemsJson"
};
for (var i = 0; i < headers.Length; i++)
{
worksheet.Cell(1, i + 1).Value = headers[i];
}
// 3. 写入数据行
for (var rowIndex = 0; rowIndex < billings.Count; rowIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
var billing = billings[rowIndex];
var totalAmount = billing.CalculateTotalAmount();
var r = rowIndex + 2;
worksheet.Cell(r, 1).Value = billing.Id;
worksheet.Cell(r, 2).Value = billing.TenantId;
worksheet.Cell(r, 3).Value = billing.StatementNo;
worksheet.Cell(r, 4).Value = billing.BillingType.ToString();
worksheet.Cell(r, 5).Value = billing.Status.ToString();
worksheet.Cell(r, 6).Value = billing.PeriodStart.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 7).Value = billing.PeriodEnd.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 8).Value = billing.AmountDue;
worksheet.Cell(r, 9).Value = billing.DiscountAmount;
worksheet.Cell(r, 10).Value = billing.TaxAmount;
worksheet.Cell(r, 11).Value = totalAmount;
worksheet.Cell(r, 12).Value = billing.AmountPaid;
worksheet.Cell(r, 13).Value = billing.Currency;
worksheet.Cell(r, 14).Value = billing.DueDate.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 15).Value = billing.Notes ?? string.Empty;
worksheet.Cell(r, 16).Value = billing.LineItemsJson ?? string.Empty;
}
// 4. 自动调整列宽并输出
worksheet.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return Task.FromResult(stream.ToArray());
}
/// <inheritdoc />
public Task<byte[]> ExportToPdfAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 生成 PDF 文档(避免复杂表格,按条目输出)
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(20);
page.DefaultTextStyle(x => x.FontSize(10));
page.Content().Column(column =>
{
column.Spacing(6);
// 2. 标题
column.Item().Text("Billings Export").FontSize(16).SemiBold();
// 3. 逐条输出
for (var i = 0; i < billings.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var b = billings[i];
var total = b.CalculateTotalAmount();
column.Item().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(item =>
{
item.Spacing(2);
item.Item().Text($"StatementNo: {b.StatementNo}");
item.Item().Text($"TenantId: {b.TenantId} BillingType: {b.BillingType} Status: {b.Status}");
item.Item().Text($"Period: {b.PeriodStart:yyyy-MM-dd} ~ {b.PeriodEnd:yyyy-MM-dd} DueDate: {b.DueDate:yyyy-MM-dd}");
item.Item().Text($"AmountDue: {b.AmountDue:0.##} Discount: {b.DiscountAmount:0.##} Tax: {b.TaxAmount:0.##}");
item.Item().Text($"Total: {total:0.##} Paid: {b.AmountPaid:0.##} Currency: {b.Currency}");
if (!string.IsNullOrWhiteSpace(b.Notes))
{
item.Item().Text($"Notes: {b.Notes}");
}
});
}
});
});
});
// 4. 输出字节
var bytes = document.GeneratePdf();
return Task.FromResult(bytes);
}
/// <inheritdoc />
public async Task<byte[]> ExportToCsvAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 使用 UTF-8 BOM便于 Excel 直接打开
await using var stream = new MemoryStream();
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true
};
await using var csv = new CsvWriter(writer, config);
// 2. 写入表头
csv.WriteField("Id");
csv.WriteField("TenantId");
csv.WriteField("StatementNo");
csv.WriteField("BillingType");
csv.WriteField("Status");
csv.WriteField("PeriodStart");
csv.WriteField("PeriodEnd");
csv.WriteField("AmountDue");
csv.WriteField("DiscountAmount");
csv.WriteField("TaxAmount");
csv.WriteField("TotalAmount");
csv.WriteField("AmountPaid");
csv.WriteField("Currency");
csv.WriteField("DueDate");
csv.WriteField("Notes");
csv.WriteField("LineItemsJson");
await csv.NextRecordAsync();
// 3. 写入数据行
foreach (var b in billings)
{
cancellationToken.ThrowIfCancellationRequested();
var total = b.CalculateTotalAmount();
csv.WriteField(b.Id);
csv.WriteField(b.TenantId);
csv.WriteField(b.StatementNo);
csv.WriteField(b.BillingType.ToString());
csv.WriteField(b.Status.ToString());
csv.WriteField(b.PeriodStart.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.PeriodEnd.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.AmountDue);
csv.WriteField(b.DiscountAmount);
csv.WriteField(b.TaxAmount);
csv.WriteField(total);
csv.WriteField(b.AmountPaid);
csv.WriteField(b.Currency);
csv.WriteField(b.DueDate.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.Notes ?? string.Empty);
csv.WriteField(b.LineItemsJson ?? string.Empty);
await csv.NextRecordAsync();
}
// 4. Flush 并返回字节
await writer.FlushAsync(cancellationToken);
return stream.ToArray();
}
}

View File

@@ -1,176 +0,0 @@
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<TakeoutAdminDbContext>();
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

@@ -1,171 +0,0 @@
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<TakeoutAdminDbContext>();
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

@@ -1,132 +0,0 @@
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<TakeoutAdminDbContext>();
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

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