refactor: 移除租户侧能力
This commit is contained in:
@@ -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. (空行后) 门店配置服务
|
||||
|
||||
@@ -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:00(UTC)
|
||||
var monday = date.AddDays(-daysSinceMonday);
|
||||
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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